diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 87eaa3a01..1a67f3544 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -362,6 +362,12 @@ impl_openapi_schema!( TicketOrchestrationPlanRecord, TicketOrchestrationPlanRecordList, TicketOrchestrationPlanSearchRequest, + TicketAssignmentsQuery, + TicketAssignmentsPage, + TicketAssignmentAnswerRequest, + TicketAssignmentAnswerResponse, + TicketAssignmentDiagnosticsQuery, + TicketAssignmentDiagnosticsPage, TicketQueryRequest, TicketQueryResponse, TicketQueueResponse, @@ -3198,6 +3204,60 @@ pub trait ServerApi { #[path] ticket_id: String, #[body] request: TicketCaseUpdateRequest, ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignments_page( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentsQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignments_page_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentsQuery, + ) -> Result; + + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_diagnostics( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentDiagnosticsQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_diagnostics_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentDiagnosticsQuery, + ) -> Result; + + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_answer( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentAnswerRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_answer_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentAnswerRequest, + ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_tracking_get( &self, @@ -7604,6 +7664,21 @@ impl TicketAttention { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketQueryRequest { + /// Exact reason of the current closed-case judgment; reopened/history-only reasons do not match. + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))] + pub closure_reason: Option, + /// Exact current assigned principal; retained relationships do not match. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentPrincipalSnapshot | null") + )] + pub assigned_principal: Option, + /// Explicit self-reported handling only; never inferred from Worker state. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentTrackingStatus | null") + )] + pub handling_status: Option, pub query: Option, #[serde(default)] #[cfg_attr(feature = "typescript", ts(type = "Array"))] @@ -7860,6 +7935,73 @@ pub struct TicketAssignmentTrackingHistoryPage { pub next_event_id: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentsQuery { + pub after_assignment_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} +transparent_ticket_dto!(TicketAssignmentsPage, ticket::TicketAssignmentsPage); +transparent_ticket_dto!( + TicketAssignmentAnswerRequest, + ticket::TicketAssignmentAnswer +); +transparent_ticket_dto!( + TicketAssignmentAnswerResponse, + ticket::TicketAssignmentAnswerReceipt +); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentDiagnosticsQuery { + pub assignment_id: String, + pub after_input_event_id: Option, + pub after_start_operation_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentInputDeliveryState { + NotReserved, + Reserved, + RequestPersisted, + RuntimeAccepted, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentInputDiagnostic { + pub input_event_id: String, + pub delivery_id: Option, + pub notification: TicketAssignmentInputDeliveryState, + /// Absence of a Runtime ack is not proof of failed external delivery. + pub acceptance_outcome_unknown: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentStartDiagnostic { + pub operation_id: String, + /// Durable start/assignment reservation result, not Worker liveness. + pub claim_state: String, + pub assignment_id: Option, + pub runtime_id: Option, + pub worker_id: Option, + /// Existing records do not attest that a model processed a particular input. + pub actual_processing_unknown: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentDiagnosticsPage { + pub assignment_id: String, + pub current: bool, + pub input_origin_known: bool, + /// Noncurrent/done/closed/released records are history, not pending work. + pub actionable: bool, + pub inputs: Vec, + pub next_input_event_id: Option, + /// Assignment-scoped admission evidence; not attributed to individual input. + pub starts: Vec, + pub next_start_operation_id: Option, +} + transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); transparent_ticket_dto!( WorkspaceTicketTagMutationRequest, diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index fe533c862..c154f950c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -500,6 +500,48 @@ fn validate_ticket_browser_operations( request: Some("TicketCaseUpdateRequest"), successes: &[("200", "TicketSummary")], }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", + method: "get", + operation_id: "ticket_assignments_page", + request: None, + successes: &[("200", "TicketAssignmentsPage")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", + method: "get", + operation_id: "ticket_assignment_diagnostics", + request: None, + successes: &[("200", "TicketAssignmentDiagnosticsPage")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", + method: "post", + operation_id: "ticket_assignment_answer", + request: Some("TicketAssignmentAnswer"), + successes: &[("200", "TicketAssignmentAnswerReceipt")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", + method: "get", + operation_id: "ticket_assignments_page_browser", + request: None, + successes: &[("200", "TicketAssignmentsPage")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", + method: "get", + operation_id: "ticket_assignment_diagnostics_browser", + request: None, + successes: &[("200", "TicketAssignmentDiagnosticsPage")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", + method: "post", + operation_id: "ticket_assignment_answer_browser", + request: Some("TicketAssignmentAnswer"), + successes: &[("200", "TicketAssignmentAnswerReceipt")], + }, OperationContract { path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", method: "get", diff --git a/crates/ticket/src/assignment_answer.rs b/crates/ticket/src/assignment_answer.rs new file mode 100644 index 000000000..fb794f7a7 --- /dev/null +++ b/crates/ticket/src/assignment_answer.rs @@ -0,0 +1,117 @@ +//! Continuing input is authored against an observed delegation, never a new Assign. +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentAnswer { + pub assignment_id: String, + pub expected_last_event_id: String, + pub operation_key: String, + pub body: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentAnswerReceipt { + pub assignment_id: String, + pub input_event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub event_index: u64, +} + +/// Host owns the IMMEDIATE transaction and authenticates/fences its source before +/// calling, including on replay. Reuse Ticket's existing immutable operation +/// receipts with a tagged input; these are not handling events or another ledger. +pub fn sqlite_answer_ticket_assignment_in_transaction( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + answer: &TicketAssignmentAnswer, + source_context: &serde_json::Value, + actor: &str, +) -> Result { + for value in [ + &answer.assignment_id, + &answer.expected_last_event_id, + &answer.operation_key, + actor, + ] { + if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) { + return Err(TicketError::Conflict( + "Answer requires bounded nonempty identities".into(), + )); + } + } + if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') { + return Err(TicketError::Conflict( + "Answer body must be nonempty, at most 16384 bytes and without NUL".into(), + )); + } + let input = + serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer})) + .map_err(sqlite_err)?; + let source = serde_json::to_string( + &serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}), + ) + .map_err(sqlite_err)?; + let receipt: Option<(String,String,String,String)> = conn.query_row( + "SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2", + params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?; + if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt { + if saved_ticket != ticket_id || saved_input != input || saved_source != source { + return Err(TicketError::OperationFingerprintMismatch { + operation_key: answer.operation_key.clone(), + }); + } + return serde_json::from_str(&saved_result).map_err(sqlite_err); + } + let current = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)? + .ok_or_else(|| { + TicketError::Conflict("Answer assignment does not exist in this Ticket".into()) + })?; + if !current.current + || current.last_event_id.as_deref() != Some(&answer.expected_last_event_id) + || !current.tracking.as_ref().is_some_and(|tracking| { + matches!( + tracking.status, + TicketAssignmentTrackingStatus::Waiting + | TicketAssignmentTrackingStatus::Inprogress + ) + }) + { + return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into())); + } + let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?; + if !open { + return Err(TicketError::Conflict( + "A closed Ticket does not admit continuing work input".into(), + )); + } + let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?; + let at = now_utc(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?; + for (key, value) in [ + ("orchestrator_assignment_id", answer.assignment_id.as_str()), + ( + "expected_last_event_id", + answer.expected_last_event_id.as_str(), + ), + ("operation_key", answer.operation_key.as_str()), + ("authenticated_source", source.as_str()), + ] { + conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?; + } + conn.execute( + "UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id, at], + ) + .map_err(sqlite_err)?; + let result = TicketAssignmentAnswerReceipt { + assignment_id: answer.assignment_id.clone(), + input_event_id: format!("ticket-event:{index}"), + event_index: index as u64, + }; + let result_json = serde_json::to_string(&result).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?; + Ok(result) +} diff --git a/crates/ticket/src/assignment_tracking.rs b/crates/ticket/src/assignment_tracking.rs index 9aada331a..6a4853c09 100644 --- a/crates/ticket/src/assignment_tracking.rs +++ b/crates/ticket/src/assignment_tracking.rs @@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> #[cfg(test)] #[path = "assignment_tracking_tests.rs"] mod tests; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentsPage { + pub items: Vec, + pub next_assignment_id: Option, +} + +/// Immutable append-order identities. The cursor must be an identity in this +/// Ticket/Workspace; timestamps and lexical IDs are not insertion order. +pub fn sqlite_ticket_assignments_page( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(TicketError::Conflict( + "Assignment page limit must be 1..100".into(), + )); + } + let after: i64 = match after_assignment_id { + None => 0, + Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)? + .ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?, + }; + let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?; + let mut ids = stmt + .query_map( + params![workspace_id, ticket_id, after, (limit + 1) as i64], + |r| r.get::<_, String>(0), + ) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + let has_more = ids.len() > limit; + ids.truncate(limit); + let next_assignment_id = if has_more { ids.last().cloned() } else { None }; + let items = ids + .iter() + .map(|id| { + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)? + .ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into())) + }) + .collect::>>()?; + Ok(TicketAssignmentsPage { + items, + next_assignment_id, + }) +} diff --git a/crates/ticket/src/assignment_tracking_tests.rs b/crates/ticket/src/assignment_tracking_tests.rs index ca66d3636..c5ea6d1c3 100644 --- a/crates/ticket/src/assignment_tracking_tests.rs +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals .contains("differs from canonical schema") ); } + +#[test] +fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let answer = TicketAssignmentAnswer { + assignment_id: "a".into(), + expected_last_event_id: "assigned-a".into(), + operation_key: "answer-a".into(), + body: "Continue with B".into(), + }; + let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"}); + let apply = |answer: &TicketAssignmentAnswer| { + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + let result = sqlite_answer_ticket_assignment_in_transaction( + &conn, "w", "t", answer, &source, "human", + ); + conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" }) + .unwrap(); + result + }; + let result = apply(&answer).unwrap(); + assert_eq!(result.input_event_id, "ticket-event:0"); + assert_eq!(apply(&answer).unwrap(), result); + assert_eq!( + conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r + .get::<_, String>(0)) + .unwrap(), + answer.body + ); + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a"); + assert_eq!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .last_event_id + .as_deref(), + Some("assigned-a") + ); + let mut altered = answer.clone(); + altered.body = "Changed".into(); + assert!(apply(&altered).is_err()); + assign(&conn, "b", "assigned-b"); + assert_eq!(apply(&answer).unwrap(), result); + altered.operation_key = "fresh-stale".into(); + assert!(apply(&altered).is_err()); +} + +#[test] +fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let original = TicketAssignmentAnswer { + assignment_id: "a".into(), + expected_last_event_id: "assigned-a".into(), + operation_key: "answer-a".into(), + body: "Answer".into(), + }; + for field in [ + "body-large", + "body-nul", + "body-empty", + "identity-control", + "event-empty", + ] { + let mut answer = original.clone(); + match field { + "body-large" => answer.body = "a".repeat(16385), + "body-nul" => answer.body = "answer\0hidden".into(), + "body-empty" => answer.body = " ".into(), + "identity-control" => answer.operation_key = "key\n".into(), + _ => answer.expected_last_event_id = "".into(), + } + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + sqlite_answer_ticket_assignment_in_transaction( + &conn, + "w", + "t", + &answer, + &serde_json::json!({"actor":"source"}), + "human" + ) + .is_err(), + "{field}" + ); + conn.execute_batch("ROLLBACK").unwrap(); + } + assert_eq!( + conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); +} diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index d65d78b1a..21cd3a933 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +mod assignment_answer; +pub use assignment_answer::*; mod assignment_tracking; pub mod config; mod sqlite_schema; diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index e18692d89..47cc144db 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -18,6 +18,15 @@ use crate::auth::{ use crate::runtime::RuntimeWorkspaceScope; use crate::worker_backend::WorkspacePromptProjectionCache; +fn ticket_assignment_role_segment(role: server_api::TicketAssignmentRole) -> &'static str { + match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + } +} + pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60; #[derive(Clone, Debug, PartialEq, Eq)] @@ -590,6 +599,62 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; runtime.block_on(async move { match operation { + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id, + role, + request, + } => { + let role = ticket_assignment_role_segment(role); + generated_workspace_response( + client + .ticket_assignment_set( + workspace_id, + ticket_id, + role.to_owned(), + request, + ) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id, + role, + query, + } => { + let role = ticket_assignment_role_segment(role); + generated_workspace_response( + client + .ticket_assignment_clear( + workspace_id, + ticket_id, + role.to_owned(), + query, + ) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => { + generated_workspace_response( + client + .ticket_assignments_page(workspace_id, ticket_id, query) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id, + query, + } => generated_workspace_response( + client + .ticket_assignment_diagnostics(workspace_id, ticket_id, query) + .await, + ), + WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => { + generated_workspace_response( + client + .ticket_assignment_answer(workspace_id, ticket_id, request) + .await, + ) + } WorkspaceServerOperation::WorkerControlList => { generated_workspace_response( client.worker_control_list(workspace_id).await, @@ -882,6 +947,125 @@ mod tests { (base_url, server) } + #[test] + fn generated_ticket_assignment_transport_signs_exact_authoring_and_bounded_read_subjects() { + use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source}; + let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let assignment_result = + serde_json::json!({"workspace_id":"workspace-a", "ticket_id":"T-1", "assignment":null}); + let answer: server_api::TicketAssignmentAnswerRequest = serde_json::from_value(serde_json::json!({ "assignment_id": "delegation", "expected_last_event_id": "observed", "operation_key": "answer-op", "body": "User answer\ncontinued" })).unwrap(); + let cases = vec![ + ( + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id: "T-1".into(), + role: server_api::TicketAssignmentRole::Orchestrator, + request: server_api::SetTicketRoleAssignmentRequest { + operation_id: "assign-op".into(), + expected_assignment_id: Some("delegation".into()), + orchestrator_assignment_id: None, + principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + workdir_bindings: vec![], + }, + }, + "PUT", + "/api/w/workspace-a/tickets/T-1/assignments/orchestrator", + assignment_result.clone(), + ), + ( + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id: "T-1".into(), + role: server_api::TicketAssignmentRole::Orchestrator, + query: server_api::ClearTicketRoleAssignmentQuery { + operation_id: Some("clear-op".into()), + assignment_id: Some("delegation".into()), + }, + }, + "DELETE", + "/api/w/workspace-a/tickets/T-1/assignments/orchestrator?operation_id=clear-op&assignment_id=delegation", + assignment_result, + ), + ( + WorkspaceServerOperation::TicketAssignmentsPage { + ticket_id: "T-1".into(), + query: server_api::TicketAssignmentsQuery { + after_assignment_id: Some("old id".into()), + limit: Some(1), + }, + }, + "GET", + "/workspaces/workspace-a/tickets/T-1/assignment-tracking/assignments?after_assignment_id=old+id&limit=1", + serde_json::json!({"items":[],"next_assignment_id":null}), + ), + ( + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id: "T-1".into(), + query: server_api::TicketAssignmentDiagnosticsQuery { + assignment_id: "delegation".into(), + after_input_event_id: Some("ticket-event:1".into()), + after_start_operation_id: None, + limit: Some(1), + }, + }, + "GET", + "/workspaces/workspace-a/tickets/T-1/assignment-diagnostics?assignment_id=delegation&after_input_event_id=ticket-event%3A1&limit=1", + serde_json::json!({"assignment_id":"delegation","current":true,"input_origin_known":true,"actionable":true,"inputs":[],"starts":[],"next_input_event_id":null,"next_start_operation_id":null}), + ), + ( + WorkspaceServerOperation::TicketAssignmentAnswer { + ticket_id: "T-1".into(), + request: answer.clone(), + }, + "POST", + "/workspaces/workspace-a/tickets/T-1/assignment-answer", + serde_json::json!({"assignment_id":"delegation","input_event_id":"ticket-event:1","event_index":1}), + ), + ]; + for (operation, method, path, reply) in cases { + let reply = reply.to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes(); + let (url, server) = binary_loopback(response); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a") + .with_runtime_request_source(&identity, "server-a"); + let result = client.execute_server_operation(operation).unwrap(); + assert_eq!(result.status, 200); + let (head, body) = server.join().unwrap(); + assert!( + head.starts_with(&format!("{method} {path} HTTP/1.1\r\n")), + "unexpected request: {head}" + ); + let token = head + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .then(|| value.trim()) + }) + .unwrap(); + let digest = request_body_digest(&body); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-a", + audience: "server-a", + workspace_id: "workspace-a", + worker_id: Some("worker-a"), + permission: WORKSPACE_REQUEST_PERMISSION, + method, + path, + body_digest: &digest, + now_unix: unix_now_seconds() as i64, + }; + verify_runtime_request_source(token, &identity.public_key, &expected).unwrap(); + if method == "POST" { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + serde_json::to_value(&answer).unwrap() + ); + } + } + } + #[test] fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() { use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source}; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index a6e6e527a..bac5576b9 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -4,6 +4,7 @@ //! module binds an authority-scoped Workspace client, declares the built-in //! feature, and contributes those tools through the normal registry path. +mod assignment_public; mod assignment_tracking; mod tags; @@ -134,7 +135,14 @@ enum WorkspaceTicketSort { } #[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] struct WorkspaceQueryTicketInput { + /// Exact current closed-case reason; reopened/history-only reasons do not match. + closure_reason: Option, + /// Exact current assigned principal; retained relationships do not match. + assigned_principal: Option, + /// Explicit self-reported handling, never inferred from Worker/MR/delivery. + handling_status: Option, /// Full-text match over Ticket title, item body, and bounded thread excerpts. query: Option, /// Exact case states (open or closed). Empty means every state. @@ -444,6 +452,9 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { "TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(), + name if assignment_public::TOOL_NAMES.contains(&name) => { + assignment_public::description(name).into() + } name if assignment_tracking::TOOL_NAMES.contains(&name) => { assignment_tracking::description(name).into() } @@ -586,6 +597,17 @@ impl TicketFeatureAccess { .copied() .filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow), ) + .chain( + assignment_public::TOOL_NAMES + .iter() + .copied() + .filter(|name| { + assignment_public::READ_TOOL_NAMES.contains(name) + || self.authoring + || self.workflow + || (*name == "TicketAssignmentAnswer" && self.thread) + }), + ) .collect() } @@ -597,7 +619,6 @@ impl TicketFeatureAccess { || (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow)) || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) || (self.thread && THREAD_TOOL_NAMES.contains(&name)) - || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) || (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name)) } } @@ -608,13 +629,13 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[ "WorkspaceTagList", "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", ]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", - "TicketMarkReady", - "TicketQueue", "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", @@ -622,8 +643,6 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; -const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; - #[cfg(test)] const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -631,8 +650,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketMarkReady", - "TicketQueue", "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", @@ -643,6 +660,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketTagsSet", "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; #[cfg(test)] @@ -664,6 +686,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", "TicketAssignmentTrackingUpdate", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -1518,20 +1545,32 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, - "TicketMarkReady" => NativeTicketOperation { - operation: "mark_ready", + "TicketOrchestratorAssign" => NativeTicketOperation { + operation: "orchestrator_assign", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "TicketIntakeReady" => NativeTicketOperation { - operation: "intake_ready", + "TicketOrchestratorClear" => NativeTicketOperation { + operation: "orchestrator_clear", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "TicketQueue" => NativeTicketOperation { - operation: "queue", + "TicketAssignmentsQuery" => NativeTicketOperation { + operation: "assignments_query", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentDiagnostics" => NativeTicketOperation { + operation: "assignment_diagnostics", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentAnswer" => NativeTicketOperation { + operation: "assignment_answer", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, @@ -1652,6 +1691,17 @@ pub(crate) fn enabled_ticket_definitions( definitions.push(assignment_tracking::definition(client.clone(), kind)); } } + for kind in [ + assignment_public::PublicAssignmentKind::Assign, + assignment_public::PublicAssignmentKind::Clear, + assignment_public::PublicAssignmentKind::Assignments, + assignment_public::PublicAssignmentKind::Diagnostics, + assignment_public::PublicAssignmentKind::Answer, + ] { + if allowed.contains(&kind.name()) { + definitions.push(assignment_public::definition(client.clone(), kind)); + } + } definitions } @@ -2134,15 +2184,6 @@ fn record_affected_ticket_mutations( { affected.insert(target.to_string()); } - if let Some(queued) = response.get("queued_tickets").and_then(Value::as_array) { - affected.extend( - queued - .iter() - .filter_map(Value::as_str) - .filter(|reference| is_ticket_route_reference(reference)) - .map(ToOwned::to_owned), - ); - } for reference in affected.difference(&subject_references) { record_ticket_observation(observations, Some(reference), &Value::Null, true); } @@ -2966,7 +3007,7 @@ mod tests { } #[test] - fn related_ticket_mutations_invalidate_target_and_queue_observations() { + fn related_ticket_mutation_invalidates_inverse_target_but_not_unrelated_tickets() { let observations = Arc::new(Mutex::new(TicketObservationState::default())); for ticket in ["T-42", "T-43", "T-44"] { record_ticket_observation( @@ -2983,22 +3024,21 @@ mod tests { observations: Arc::clone(&observations), }; let target_v1 = resolver.resolve("T-43").unwrap().object.validator; - let queued_v1 = resolver.resolve("T-44").unwrap().object.validator; + let unrelated_v1 = resolver.resolve("T-44").unwrap().object.validator; record_affected_ticket_mutations( &observations, "T-42", &json!({ "ticket": "T-42", - "target": "T-43", - "queued_tickets": ["T-42", "T-44"] + "target": "T-43" }), ); assert_ne!( target_v1, resolver.resolve("T-43").unwrap().object.validator ); - assert_ne!( - queued_v1, + assert_eq!( + unrelated_v1, resolver.resolve("T-44").unwrap().object.validator ); } @@ -3168,7 +3208,7 @@ mod tests { .collect::>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); - assert!(workspace_tools.contains(&"TicketQueue")); + assert!(!workspace_tools.contains(&"TicketQueue")); assert!(workspace_tools.contains(&"TicketCaseUpdate")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); @@ -3205,6 +3245,57 @@ mod tests { assert!(!review_tools.contains(&"TicketCaseUpdate")); } + #[test] + fn normal_and_wip_catalogs_exclude_retired_ticket_workflow_operations() { + let client: Arc = Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://unused"), + ); + let retired = [ + "TicketMarkReady", + "TicketIntakeReady", + "TicketQueue", + "TicketWorkflowState", + "CompleteTicket", + "TicketClose", + ]; + for access in [ + TicketFeatureAccess::workspace_authoring(), + TicketFeatureAccess::intake(), + TicketFeatureAccess::workflow(), + TicketFeatureAccess::work_report(), + TicketFeatureAccess::read_only(), + TicketFeatureAccess { + authoring: true, + thread: true, + intake: true, + workflow: true, + }, + ] { + let normal = enabled_ticket_definitions(client.clone(), access); + let native = native_ticket_tools(client.clone(), access).unwrap(); + for name in retired { + assert!( + normal.iter().all(|definition| definition().0.name != name), + "{name}" + ); + assert!(native.iter().all(|tool| tool.name != name), "{name}"); + assert!(native_ticket_operation(name).is_none(), "{name}"); + } + if access.authoring || access.thread || access.workflow { + assert!( + normal + .iter() + .any(|definition| definition().0.name == "TicketCaseUpdate") + ); + assert!( + native + .iter() + .any(|tool| tool.projection.operation == "case_update") + ); + } + } + } + #[test] fn read_only_installation_does_not_expose_mutating_tools() { let mut pending_tools = Vec::new(); @@ -3248,8 +3339,8 @@ mod tests { assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES); assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); - assert!(installed.iter().any(|tool| *tool == "TicketQueue")); - assert!(installed.iter().any(|tool| *tool == "TicketMarkReady")); + assert!(!installed.iter().any(|tool| *tool == "TicketQueue")); + assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); assert!(installed.iter().any(|tool| *tool == "TicketCaseUpdate")); assert!( diff --git a/crates/worker/src/feature/builtin/ticket/assignment_public.rs b/crates/worker/src/feature/builtin/ticket/assignment_public.rs new file mode 100644 index 000000000..283eeb8c8 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_public.rs @@ -0,0 +1,340 @@ +//! Public delegation operations over shared host-bound typed Workspace forwarding. +//! Source/actor authorization and assignment/receipt authority remain Backend-owned. +#[cfg(test)] +mod tests; +use super::*; +use crate::worker::WorkspaceServerOperation; + +pub(super) const TOOL_NAMES: &[&str] = &[ + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", +]; +pub(super) const READ_TOOL_NAMES: &[&str] = + &["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"]; + +#[derive(Clone, Copy)] +pub(super) enum PublicAssignmentKind { + Assign, + Clear, + Assignments, + Diagnostics, + Answer, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AssignInput { + ticket: String, + /// Target principal, not the authenticated actor. Backend validates role/principal pairing. + principal: server_api::TicketAssignmentPrincipal, + /// Observed current assignment ID, or explicitly null for an unassigned Ticket. + #[serde(deserialize_with = "observed_assignment")] + expected_assignment_id: Option, + /// Stable identity of this Assign/reissue; exact retries keep the same full input. + operation_id: String, +} +fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Option::::deserialize(d) +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ClearInput { + ticket: String, + /// Observed exact current delegation to release, not Worker identity/status. + assignment_id: String, + operation_id: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AssignmentsInput { + ticket: String, + #[serde(flatten)] + query: server_api::TicketAssignmentsQuery, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct DiagnosticsInput { + ticket: String, + #[serde(flatten)] + query: server_api::TicketAssignmentDiagnosticsQuery, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AnswerInput { + ticket: String, + #[serde(flatten)] + request: server_api::TicketAssignmentAnswerRequest, +} + +impl PublicAssignmentKind { + pub(super) fn name(self) -> &'static str { + match self { + Self::Assign => TOOL_NAMES[0], + Self::Clear => TOOL_NAMES[1], + Self::Assignments => TOOL_NAMES[2], + Self::Diagnostics => TOOL_NAMES[3], + Self::Answer => TOOL_NAMES[4], + } + } + fn schema(self) -> Value { + match self { + Self::Assign => { + let mut schema = json!(schemars::schema_for!(AssignInput)); + let required = schema["required"] + .as_array_mut() + .expect("Assign object schema"); + if !required + .iter() + .any(|field| field == "expected_assignment_id") + { + required.push(json!("expected_assignment_id")); + } + schema + } + Self::Clear => json!(schemars::schema_for!(ClearInput)), + Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)), + Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)), + Self::Answer => json!(schemars::schema_for!(AnswerInput)), + } + } +} +pub(super) fn description(name: &str) -> &'static str { + match name { + "TicketOrchestratorAssign" => { + "Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge." + } + "TicketOrchestratorClear" => { + "Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model." + } + "TicketAssignmentsQuery" => { + "Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation." + } + "TicketAssignmentDiagnostics" => { + "Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state." + } + "TicketAssignmentAnswer" => { + "Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start." + } + _ => unreachable!("unknown public assignment tool"), + } +} +fn identity(name: &str, value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) { + return Err(ToolError::InvalidArgument(format!( + "{name} must be nonempty, control-free and at most 1024 bytes" + ))); + } + Ok(()) +} +fn page(limit: Option) -> Result { + let limit = limit.unwrap_or(100); + if !(1..=100).contains(&limit) { + return Err(ToolError::InvalidArgument( + "limit must be between 1 and 100".into(), + )); + } + Ok(limit) +} +fn optional_identity(name: &str, value: &Option) -> Result<(), ToolError> { + value + .as_deref() + .map(|value| identity(name, value)) + .transpose() + .map(|_| ()) +} + +struct PublicAssignmentTool { + client: Arc, + kind: PublicAssignmentKind, +} +#[async_trait] +impl Tool for PublicAssignmentTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + self.client + .workspace_id() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + ToolError::InvalidArgument( + "Assignment tools require Backend Workspace identity; no local fallback".into(), + ) + })?; + let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string()); + let operation = match self.kind { + PublicAssignmentKind::Assign => { + let input: AssignInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("operation_id", &input.operation_id)?; + optional_identity("expected_assignment_id", &input.expected_assignment_id)?; + match &input.principal { + server_api::TicketAssignmentPrincipal::User { account_id } => { + identity("account_id", account_id)? + } + server_api::TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => { + identity("runtime_id", runtime_id)?; + identity("worker_id", worker_id)?; + } + server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + identity("agent_key", agent_key)? + } + } + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id: input.ticket, + role: server_api::TicketAssignmentRole::Orchestrator, + request: server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, + operation_id: input.operation_id, + principal: input.principal, + expected_assignment_id: input.expected_assignment_id, + workdir_bindings: vec![], + }, + } + } + PublicAssignmentKind::Clear => { + let input: ClearInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("assignment_id", &input.assignment_id)?; + identity("operation_id", &input.operation_id)?; + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id: input.ticket, + role: server_api::TicketAssignmentRole::Orchestrator, + query: server_api::ClearTicketRoleAssignmentQuery { + operation_id: Some(input.operation_id), + assignment_id: Some(input.assignment_id), + }, + } + } + PublicAssignmentKind::Assignments => { + let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + optional_identity("after_assignment_id", &input.query.after_assignment_id)?; + input.query.limit = Some(page(input.query.limit)?); + WorkspaceServerOperation::TicketAssignmentsPage { + ticket_id: input.ticket, + query: input.query, + } + } + PublicAssignmentKind::Diagnostics => { + let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("assignment_id", &input.query.assignment_id)?; + optional_identity("after_input_event_id", &input.query.after_input_event_id)?; + optional_identity( + "after_start_operation_id", + &input.query.after_start_operation_id, + )?; + input.query.limit = Some(page(input.query.limit)?); + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id: input.ticket, + query: input.query, + } + } + PublicAssignmentKind::Answer => { + let input: AnswerInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + for (name, value) in [ + ("assignment_id", &input.request.0.assignment_id), + ( + "expected_last_event_id", + &input.request.0.expected_last_event_id, + ), + ("operation_key", &input.request.0.operation_key), + ] { + identity(name, value)?; + } + let body = &input.request.0.body; + if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') { + return Err(ToolError::InvalidArgument( + "Answer body must be nonempty, at most 16384 bytes and without NUL".into(), + )); + } + WorkspaceServerOperation::TicketAssignmentAnswer { + ticket_id: input.ticket, + request: input.request, + } + } + }; + let response = self + .client + .execute_server_operation(operation) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + return Err(ToolError::ExecutionFailed(format!( + "Assignment request failed with HTTP status {}: {}", + response.status, + response.body.chars().take(1024).collect::() + ))); + } + if response.body.len() > 1024 * 1024 { + return Err(ToolError::ExecutionFailed( + "Assignment response exceeds 1 MiB; request a smaller page".into(), + )); + } + // Decode the actual public DTO and serialize only its declared fields; no raw + // backend payload/credentials or alternative result authority reaches the model. + let decode = |error: serde_json::Error| { + ToolError::ExecutionFailed(format!( + "Invalid Backend assignment response (outcome unknown after dispatch): {error}" + )) + }; + let content = match self.kind { + PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value( + serde_json::from_str::( + &response.body, + ) + .map_err(decode)?, + ), + PublicAssignmentKind::Assignments => { + let result: server_api::TicketAssignmentsPage = + serde_json::from_str(&response.body).map_err(decode)?; + if result.0.items.len() > 100 { + return Err(ToolError::ExecutionFailed( + "Assignment page exceeds 100 items".into(), + )); + } + serde_json::to_value(result) + } + PublicAssignmentKind::Diagnostics => { + let result: server_api::TicketAssignmentDiagnosticsPage = + serde_json::from_str(&response.body).map_err(decode)?; + if result.inputs.len() > 100 || result.starts.len() > 100 { + return Err(ToolError::ExecutionFailed( + "Diagnostic page exceeds 100 items".into(), + )); + } + serde_json::to_value(result) + } + PublicAssignmentKind::Answer => serde_json::to_value( + serde_json::from_str::(&response.body) + .map_err(decode)?, + ), + } + .map_err(|error| ToolError::Internal(error.to_string()))?; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some(content.to_string()), + attachments: vec![], + }) + } +} +pub(super) fn definition( + client: Arc, + kind: PublicAssignmentKind, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(description(kind.name())) + .input_schema(kind.schema()), + Arc::new(PublicAssignmentTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} diff --git a/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs b/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs new file mode 100644 index 000000000..144a2c692 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs @@ -0,0 +1,598 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + +#[derive(Debug)] +struct Transport { + requests: Mutex>, + response: Result, + available: bool, +} +impl WorkspaceClient for Transport { + fn workspace_id(&self) -> Option<&str> { + self.available.then_some("workspace") + } + fn kind(&self) -> &str { + "assignment-test" + } + fn is_available(&self) -> bool { + self.available + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + self.response.clone().map_err(WorkspaceClientError::Request) + } +} +fn transport(status: u16, body: Value) -> Arc { + Arc::new(Transport { + requests: Mutex::new(vec![]), + available: true, + response: Ok(WorkspaceResponse { + status, + body: body.to_string(), + }), + }) +} +fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> { + vec![ + ( + PublicAssignmentKind::Assign, + json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}), + json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}), + ), + ( + PublicAssignmentKind::Clear, + json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}), + json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}), + ), + ( + PublicAssignmentKind::Assignments, + json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}), + json!({"items":[], "next_assignment_id":null}), + ), + ( + PublicAssignmentKind::Diagnostics, + json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}), + json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}), + ), + ( + PublicAssignmentKind::Answer, + json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}), + json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}), + ), + ] +} +#[tokio::test] +async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() { + for (kind, input, response) in cases() { + let client = transport(200, response); + let (meta, tool) = definition(client.clone(), kind)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!( + schema.is_valid(&input), + "{}: {}", + meta.name, + meta.input_schema + ); + for field in meta.input_schema["required"] + .as_array() + .into_iter() + .flatten() + { + let mut missing = input.clone(); + missing + .as_object_mut() + .unwrap() + .remove(field.as_str().unwrap()); + assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name); + assert!(matches!( + tool.execute(&missing.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for actor_claim in [ + "actor", + "author", + "role", + "runtime_id", + "worker_id", + "authentication_binding_id", + ] { + let mut forged = input.clone(); + forged[actor_claim] = json!("authority"); + assert!( + !schema.is_valid(&forged), + "{} accepts {actor_claim}", + meta.name + ); + assert!(matches!( + tool.execute(&forged.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + assert!(client.requests.lock().unwrap().is_empty()); + } +} +#[tokio::test] +async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() { + for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| { + matches!( + kind, + PublicAssignmentKind::Assign | PublicAssignmentKind::Clear + ) + }) { + if matches!(kind, PublicAssignmentKind::Assign) { + input["expected_assignment_id"] = json!("previous-done"); + } + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for _ in 0..2 { + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0], requests[1]); + assert!( + requests[0] + .path + .starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator") + ); + match kind { + PublicAssignmentKind::Assign => { + assert_eq!(requests[0].method, WorkspaceRequestMethod::Put); + let body: Value = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["expected_assignment_id"], "previous-done"); + assert_eq!(body["operation_id"], input["operation_id"]); + assert_eq!(body["principal"], input["principal"]); + assert!(body.get("actor").is_none()); + assert!(body.get("orchestrator_assignment_id").is_none()); + assert_eq!(body["workdir_bindings"], json!([])); + } + PublicAssignmentKind::Clear => { + assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete); + assert!(requests[0].path.contains("assignment_id=delegation")); + assert!(requests[0].path.contains("operation_id=clear-1")); + assert!(requests[0].body.is_none()); + } + _ => unreachable!(), + } + } +} +#[tokio::test] +async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() { + let (kind, input, response) = cases().pop().unwrap(); + let client = transport(200, response.clone()); + let (_, tool) = definition(client.clone(), kind)(); + for _ in 0..2 { + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()).unwrap(), + response + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests[0], requests[1]); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-743/assignment-answer" + ); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + let mut expected = input; + expected.as_object_mut().unwrap().remove("ticket"); + assert_eq!(body, expected); + assert!(body.get("tracking").is_none()); +} +#[tokio::test] +async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() { + for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| { + matches!( + kind, + PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics + ) + }) { + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for limit in [0, 101] { + let mut invalid = input.clone(); + invalid["limit"] = json!(limit); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert!(requests[0].path.contains("limit=10")); + match kind { + PublicAssignmentKind::Assignments => assert!( + requests[0] + .path + .contains("after_assignment_id=old%2Fdelegation%3F") + ), + PublicAssignmentKind::Diagnostics => { + assert!( + requests[0] + .path + .contains("after_input_event_id=ticket-event%3A7") + ); + assert!( + requests[0] + .path + .contains("after_start_operation_id=start%2F7") + ); + } + _ => unreachable!(), + } + } +} +#[tokio::test] +async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() { + for (kind, input, _) in cases() { + for response in [ + Ok(WorkspaceResponse { + status: 403, + body: "permission denied".into(), + }), + Ok(WorkspaceResponse { + status: 409, + body: "stale assignment or operation fingerprint mismatch".into(), + }), + Ok(WorkspaceResponse { + status: 200, + body: "invalid response after dispatch".into(), + }), + Err("response lost; outcome unknown".into()), + ] { + let client = Arc::new(Transport { + requests: Mutex::new(vec![]), + available: true, + response, + }); + let (_, tool) = definition(client.clone(), kind)(); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + client.requests.lock().unwrap().len(), + 1, + "{} retried", + kind.name() + ); + } + let client = Arc::new(Transport { + requests: Mutex::new(vec![]), + available: false, + response: Err("unavailable".into()), + }); + let (_, tool) = definition(client.clone(), kind)(); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("Backend Workspace identity") + ); + assert!(client.requests.lock().unwrap().is_empty()); + } +} +#[tokio::test] +async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() { + let (kind, input, mut response) = cases().remove(3); + response["rendered_content"] = json!("must not reach model"); + response["authentication_binding_id"] = json!("must not reach model"); + response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]); + response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]); + let (_, tool) = definition(transport(200, response), kind)(); + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(output.get("rendered_content").is_none()); + assert!(output.get("authentication_binding_id").is_none()); + assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true); + assert!(output["inputs"][0].get("rendered_content").is_none()); + assert_eq!(output["starts"][0]["actual_processing_unknown"], true); + assert!( + output["starts"][0] + .get("authentication_binding_id") + .is_none() + ); +} +#[test] +fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() { + for access in [ + TicketFeatureAccess::read_only(), + TicketFeatureAccess::review(), + ] { + let client = transport(200, json!({})); + let normal = enabled_ticket_definitions(client.clone(), access); + let native = native_ticket_tools(client, access).unwrap(); + for name in READ_TOOL_NAMES { + assert!(normal.iter().any(|d| d().0.name == *name)); + assert!(native.iter().any(|t| t.name == *name)); + } + for name in [ + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentAnswer", + ] { + assert!(!normal.iter().any(|d| d().0.name == name)); + assert!(!native.iter().any(|t| t.name == name)); + } + } + for name in TOOL_NAMES { + let native = native_ticket_operation(name).unwrap(); + assert_eq!(native.identity_field, Some("ticket")); + assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name)); + } +} +#[tokio::test] +async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() { + let client = transport( + 200, + json!({"items":[],"page":{"has_more":false,"next_cursor":null}}), + ); + let (meta, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]}); + assert!(schema.is_valid(&input)); + let invalid = json!({"closure_reason":"bug"}); + assert!(!schema.is_valid(&invalid)); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["closure_reason"], "completed"); + assert_eq!(body["tag_ids"], json!(["tag-bug"])); +} + +#[tokio::test] +async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() { + let client = transport( + 200, + json!({"items":[],"page":{"has_more":false,"next_cursor":null}}), + ); + let (meta, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5}); + assert!(schema.is_valid(&input)); + for status in ["running", "idle", "stopped", "closed"] { + let mut invalid = input.clone(); + invalid["handling_status"] = json!(status); + assert!(!schema.is_valid(&invalid)); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["assigned_principal"], input["assigned_principal"]); + assert_eq!(body["handling_status"], "waiting"); + assert_eq!(body["states"], json!(["open"])); +} + +#[test] +fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() { + let dir = tempfile::tempdir().unwrap(); + for profile in [ + "intake", + "orchestrator", + "coder", + "ticket-worker", + "reviewer", + ] { + let resolved = manifest::ProfileResolver::new() + .with_workspace_base(dir.path()) + .resolve( + &manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")), + manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"), + ) + .unwrap(); + let config = resolved.manifest.feature.ticket; + assert!(config.enabled, "{profile}"); + let delegates = config.authoring || config.workflow; + let answers = delegates || config.thread; + let reports = config.workflow; + let access = TicketFeatureAccess { + authoring: config.authoring, + thread: config.thread, + intake: config.intake, + workflow: config.workflow, + }; + let names = access.tool_names(); + assert_eq!( + names.contains(&"TicketOrchestratorAssign"), + delegates, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketOrchestratorClear"), + delegates, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketAssignmentAnswer"), + answers, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketAssignmentTrackingUpdate"), + reports, + "{profile}" + ); + assert!( + names.contains(&"TicketAssignmentsQuery") + && names.contains(&"TicketAssignmentDiagnostics"), + "{profile}" + ); + assert!( + !names.iter().any(|name| matches!( + *name, + "TicketQueue" | "TicketMarkReady" | "TicketIntakeReady" + )), + "{profile}" + ); + } +} + +#[tokio::test] +async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() { + let (kind, input, response) = cases().pop().unwrap(); + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for body in [ + String::new(), + " ".into(), + "x".repeat(16385), + "text\0hidden".into(), + ] { + let mut invalid = input.clone(); + invalid["body"] = json!(body); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for field in ["assignment_id", "expected_last_event_id", "operation_key"] { + for value in [String::new(), "x\n".into(), "x".repeat(1025)] { + let mut invalid = input.clone(); + invalid[field] = json!(value); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + } + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() { + for (kind, mut input, response) in cases() { + let client = transport(200, response); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let handler = TicketItemWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|t| t.projection.surface == NativeTicketSurface::Item) + .collect(), + ), + permissions: None, + ticket_reference: "T-743".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + input.as_object_mut().unwrap().remove("ticket"); + let args: BTreeMap<_, _> = input + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), json_to_wip(value).unwrap())) + .collect(); + let operation = native_ticket_operation(kind.name()).unwrap().operation; + handler + .call( + operation, + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into(), + }, + ) + .await + .unwrap_or_else(|_| panic!("WIP {operation} rejected")); + assert_eq!(client.requests.lock().unwrap().len(), 1); + let mut forged = args.clone(); + forged.insert("ticket".into(), WipValue::String("T-other".into())); + assert!( + handler + .call( + operation, + &forged, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into() + } + ) + .await + .is_err() + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } + let (_, input, response) = cases().pop().unwrap(); + let client = transport(200, response); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let denied = TicketItemWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|t| t.projection.surface == NativeTicketSurface::Item) + .collect(), + ), + permissions: Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![], + }), + ticket_reference: "T-743".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + let args = input + .as_object() + .unwrap() + .iter() + .filter(|(k, _)| k.as_str() != "ticket") + .map(|(k, v)| (k.clone(), json_to_wip(v).unwrap())) + .collect(); + assert!(matches!( + denied + .call( + "assignment_answer", + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into() + } + ) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + assert!(client.requests.lock().unwrap().is_empty()); +} diff --git a/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs index 349d1aacf..21835d607 100644 --- a/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs +++ b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs @@ -74,7 +74,7 @@ pub(super) fn description(name: &str) -> &'static str { "Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request." } "TicketAssignmentTrackingUpdate" => { - "Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations." + "Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations." } _ => unreachable!("unknown assignment tracking tool"), } diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 4d2497661..89cac0c61 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -866,9 +866,10 @@ mod tests { .unwrap(); assert!(server.starts_with("Assigned Tickets have unreceived input:")); - // TUI's final operation model is a separate downstream scope; both - // renderers still consume the same bounded resource-key projection. - assert!(tui.starts_with("Queued Tickets require attention:")); + assert!(tui.starts_with("Assigned Tickets have input requiring attention:")); + assert!(tui.contains("no ready/Queue step is required")); + assert!(tui.contains("were omitted from this notice: 1")); + assert!(!tui.contains("T-21")); assert!(server.contains("- T-1 — Ticket 1 with control ")); assert!(!server.contains("T-21")); assert!(server.contains("were omitted from this notice: 1")); diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 294cd98d1..6cf673681 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -334,19 +334,28 @@ mod tests { fn ticket_workflow_fixture( role: &str, mr_config: MergeRequestFeatureConfig, + ) -> (String, Vec) { + ticket_fixture_with_access( + role, + mr_config, + TicketFeatureAccess { + thread: true, + workflow: true, + ..Default::default() + }, + ) + } + + fn ticket_fixture_with_access( + role: &str, + mr_config: MergeRequestFeatureConfig, + access: TicketFeatureAccess, ) -> (String, Vec) { let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")); let mut tools = Vec::new(); let mut hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() - .with_module(TicketFeature::new( - client.clone(), - TicketFeatureAccess { - thread: true, - workflow: true, - ..Default::default() - }, - )) + .with_module(TicketFeature::new(client.clone(), access)) .with_module(MergeRequestFeature::new(client, mr_config)) .install_into_pending(&mut tools, &mut hooks); assert!(!report.has_errors(), "{}", report.error_message()); @@ -471,6 +480,147 @@ mod tests { } } + #[test] + fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() { + for role in [ + "role.ticket_worker", + "role.coder", + "role.orchestrator", + "role.intake", + ] { + let (prompt, tools) = + ticket_workflow_fixture(role, MergeRequestFeatureConfig::default()); + for policy in [ + "only `open`", + "stable tag IDs, not display names", + "no ready step or separate Queue is required", + "Orchestrator's self-reported handling", + "Answers during waiting or ongoing handling continue the same assignment row", + "explicit reissue creates a new row/assignment_id", + "Respect an explicit user approval condition", + "done does not require unassignment", + "expected_last_event_id", + "Exact retries retain the same key and full input", + "Assign persistence, notification delivery, Runtime acceptance and actual work start", + "concrete API insufficiency", + ] { + assert!(prompt.contains(policy), "{role}: missing {policy}"); + } + let update = tools + .iter() + .find(|meta| meta.name == "TicketAssignmentTrackingUpdate") + .unwrap(); + for policy in [ + "self-reported", + "same row", + "new Assign row", + "complete tracking payload", + "different input with that key is rejected", + "reread current authority", + "exact retry receipts are not fresh observations", + ] { + assert!( + update.description.contains(policy), + "{role}: missing {policy}" + ); + } + assert!(!tools.iter().any(|meta| matches!( + meta.name.as_str(), + "TicketMarkReady" + | "TicketQueue" + | "TicketIntakeReady" + | "CompleteTicket" + | "TicketWorkflowState" + | "TicketClose" + ))); + if role == "role.orchestrator" { + assert!(prompt.contains("observed current `orchestrator_assignment_id`")); + assert!(prompt.contains( + "generic `WorkerSpawn` Ticket starts and existing-Worker assignment" + )); + } + } + } + + // Scripted policy/input fixtures: they do not run a model or certify its judgment. + #[test] + fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr() + { + let dir = tempfile::tempdir().unwrap(); + let resolved = manifest::ProfileResolver::new() + .with_workspace_base(dir.path()) + .resolve( + &manifest::ProfileSelector::parse_cli("builtin:orchestrator"), + manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"), + ) + .unwrap(); + let config = resolved.manifest.feature.ticket; + // Omit MR feature for the no-repository/no-MR work fixture; profile selection + // makes it available, not mandatory to exercise for every Ticket. + let (prompt, tools) = ticket_fixture_with_access( + &resolved.manifest.engine.instruction, + MergeRequestFeatureConfig::default(), + TicketFeatureAccess { + authoring: config.authoring, + thread: config.thread, + intake: config.intake, + workflow: config.workflow, + }, + ); + assert!(!tools.iter().any(|meta| matches!( + meta.name.as_str(), + "OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest" + ))); + for (policy, name, input) in [ + ( + "Use `TicketOrchestratorAssign` for explicit initial delegation or reissue", + "TicketOrchestratorAssign", + serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}), + ), + ( + "research returned to a human can be `open` + handling `done`", + "TicketAssignmentTrackingUpdate", + serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}), + ), + ( + "Respect an explicit user approval condition", + "TicketAssignmentTrackingUpdate", + serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}), + ), + ( + "use `TicketAssignmentAnswer` with that `assignment_id`", + "TicketAssignmentAnswer", + serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}), + ), + ( + "No Merge Request or approval is a prerequisite for a generic Ticket decision", + "TicketCaseUpdate", + serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}), + ), + ] { + assert!(prompt.contains(policy), "missing {policy}"); + let meta = tools.iter().find(|meta| meta.name == name).unwrap(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&input), "{name} rejected {input}"); + let required = meta.input_schema["required"].as_array().unwrap(); + assert!(!required.iter().any(|field| matches!( + field.as_str(), + Some( + "repository_key" + | "merge_request_id" + | "approval_event_id" + | "workdir_attachments" + ) + ))); + let mut forged = input; + forged["actor"] = serde_json::json!("workspace-orchestrator"); + assert!( + !validator.is_valid(&forged), + "model-supplied authority accepted" + ); + } + } + #[test] fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() { let names = [ diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 67163155d..fc1b60b22 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -729,6 +729,28 @@ pub struct WorkspaceWorkerDiscoveryRequest { #[derive(Debug, Clone)] pub enum WorkspaceServerOperation { + TicketAssignmentSet { + ticket_id: String, + role: server_api::TicketAssignmentRole, + request: server_api::SetTicketRoleAssignmentRequest, + }, + TicketAssignmentClear { + ticket_id: String, + role: server_api::TicketAssignmentRole, + query: server_api::ClearTicketRoleAssignmentQuery, + }, + TicketAssignmentsPage { + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + }, + TicketAssignmentDiagnostics { + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + }, + TicketAssignmentAnswer { + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + }, WorkerControlList, WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest), WorkerControlInput { @@ -784,6 +806,45 @@ fn encode_workspace_path_segment(value: &str) -> String { encoded } +fn workspace_server_query_request( + method: WorkspaceRequestMethod, + path: String, + query: &T, +) -> Result { + let query = serde_json::to_value(query) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + let fields = query.as_object().ok_or_else(|| { + WorkspaceClientError::Request("Workspace query must serialize to an object".into()) + })?; + let mut pairs = Vec::new(); + for (name, value) in fields { + let value = match value { + serde_json::Value::Null => continue, + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + _ => { + return Err(WorkspaceClientError::Request( + "Workspace query fields must be scalar".into(), + )); + } + }; + pairs.push(format!( + "{}={}", + encode_workspace_path_segment(name), + encode_workspace_path_segment(&value) + )); + } + Ok(WorkspaceRequest { + method, + path: if pairs.is_empty() { + path + } else { + format!("{path}?{}", pairs.join("&")) + }, + body: None, + }) +} + fn workspace_server_operation_request( workspace_id: &str, operation: WorkspaceServerOperation, @@ -791,6 +852,76 @@ fn workspace_server_operation_request( let workspace_id = encode_workspace_path_segment(workspace_id); let base = format!("/api/w/{workspace_id}"); match operation { + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id, + role, + request, + } => { + let role = match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + }; + Ok(WorkspaceRequest::json( + WorkspaceRequestMethod::Put, + format!( + "{base}/tickets/{}/assignments/{role}", + encode_workspace_path_segment(&ticket_id) + ), + serde_json::to_string(&request) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, + )) + } + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id, + role, + query, + } => { + let role = match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + }; + workspace_server_query_request( + WorkspaceRequestMethod::Delete, + format!( + "{base}/tickets/{}/assignments/{role}", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => { + workspace_server_query_request( + WorkspaceRequestMethod::Get, + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => { + workspace_server_query_request( + WorkspaceRequestMethod::Get, + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => { + workspace_server_json_request( + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-answer", + encode_workspace_path_segment(&ticket_id) + ), + &request, + ) + } WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!( "{base}/worker-control/workers" ))), diff --git a/crates/workspace-server/src/assignment_delivery.rs b/crates/workspace-server/src/assignment_delivery.rs index db59d0f51..db7135aec 100644 --- a/crates/workspace-server/src/assignment_delivery.rs +++ b/crates/workspace-server/src/assignment_delivery.rs @@ -124,12 +124,14 @@ WITH eligible AS ( SELECT a.ticket_id,a.assignment_id,'ticket-event:' || e.event_index,a.tracking_json,e.event_index FROM eligible a JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id WHERE e.event_index>a.after_ticket_event_index - -- External content/answer inputs, not case/routing/history or own self-report. - -- Ticket supports typed extension events; an answer need not be named comment. - AND e.kind NOT IN ('create','close','state_changed','status_changed','history') + -- Continuing request intent is explicit: fenced assignment answers or item content edits. + -- Ordinary comments and all case/routing/handling history are not new requests. + AND e.kind IN ('assignment_answer','item_edit') + AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=a.assignment_id)) AND e.kind NOT LIKE 'assignment_tracking%' AND e.kind NOT LIKE 'assignment_delivery%' AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id) ) SELECT i.ticket_id,i.assignment_id,i.input_event_id, json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json @@ -378,3 +380,85 @@ pub(super) fn save_operation_fence( } Ok(()) } + +// Same input selection as delivery, plus immutable delivered history. Never +// SELECT payload/source/content into the public diagnostic projection. +const DIAGNOSTIC_INPUTS_SQL: &str = r#" +WITH inputs AS ( + SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o + WHERE o.workspace_id=?1 AND o.assignment_id=?3 + UNION + SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END + FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3 + UNION + SELECT 'ticket-event:' || e.event_index,e.event_index + FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id + WHERE o.workspace_id=?1 AND o.ticket_id=?2 AND o.assignment_id=?3 AND e.event_index>o.after_ticket_event_index + AND e.kind IN ('assignment_answer','item_edit') + AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=?3)) + AND e.kind NOT LIKE 'assignment_tracking%' AND e.kind NOT LIKE 'assignment_delivery%' + AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') + -- Retain historical input identity after done/release. A later delegation's + -- origin bounds ordinary events; an explicit answer's attribute is authoritative. + AND e.event_index<=COALESCE((SELECT MIN(later.after_ticket_event_index) FROM ticket_assignment_delivery_origins later + JOIN ticket_worker_assignments la ON la.workspace_id=later.workspace_id AND la.assignment_id=later.assignment_id + JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id + WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807) + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3) +) +"#; + +pub(super) fn diagnostics_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(Error::InvalidInput( + "Diagnostic page limit must be 1..100".into(), + )); + } + store.with_conn(|conn| { + // One read snapshot for current assignment, evidence and cursor validation. + let tx = conn.unchecked_transaction()?; + let assignment = ticket::sqlite_ticket_assignment_tracking(&tx,workspace_id,ticket_id,assignment_id) + .map_err(|e| Error::Store(e.to_string()))?.ok_or_else(|| Error::InvalidInput("Assignment does not belong to this Ticket".into()))?; + let known: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3)",params![workspace_id,ticket_id,assignment_id],|r| r.get(0))?; + let actionable = match require_current(&tx,workspace_id,ticket_id,assignment_id) { + Ok(()) => true, + Err(Error::TicketAssignmentConflict(_)) => false, + Err(error) => return Err(error), + }; + let after = match after_input_event_id { + None => -2, + Some(id) => tx.query_row(&format!("{DIAGNOSTIC_INPUTS_SQL} SELECT input_order FROM inputs WHERE input_event_id=?5"),params![workspace_id,ticket_id,assignment_id,actionable,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Input cursor does not belong to this assignment".into()))?, + }; + let sql = format!("{DIAGNOSTIC_INPUTS_SQL} SELECT i.input_event_id,d.delivery_id,EXISTS(SELECT 1 FROM ticket_assignment_delivery_requests req WHERE req.workspace_id=?1 AND req.delivery_id=d.delivery_id),EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id) FROM inputs i LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=?3 AND d.input_event_id=i.input_event_id WHERE i.input_order>?5 ORDER BY i.input_order LIMIT ?6"); + let mut stmt = tx.prepare(&sql)?; + let mut inputs = stmt.query_map(params![workspace_id,ticket_id,assignment_id,actionable,after,(limit+1) as i64],|r| { + let delivery_id: Option = r.get(1)?; + let prepared: bool = r.get(2)?; + let ack: bool = r.get(3)?; + use server_api::TicketAssignmentInputDeliveryState::*; + let notification = if ack { RuntimeAccepted } else if prepared { RequestPersisted } else if delivery_id.is_some() { Reserved } else { NotReserved }; + Ok(server_api::TicketAssignmentInputDiagnostic { input_event_id:r.get(0)?, delivery_id,notification,acceptance_outcome_unknown:prepared && !ack }) + })?.collect::,_>>()?; + let more = inputs.len()>limit; + inputs.truncate(limit); + let next_input_event_id = if more { inputs.last().map(|i| i.input_event_id.clone()) } else { None }; + let after_start = match after_start_operation_id { + None => 0, + Some(id) => tx.query_row("SELECT f.rowid FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.orchestrator_assignment_id=?3 AND o.ticket_id=?2 AND f.operation_id=?4",params![workspace_id,ticket_id,assignment_id,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Start cursor does not belong to this assignment".into()))?, + }; + let mut stmt = tx.prepare("SELECT f.operation_id,o.claim_state,o.assignment_id,o.runtime_id,o.worker_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND o.ticket_id=?2 AND f.orchestrator_assignment_id=?3 AND f.rowid>?4 ORDER BY f.rowid LIMIT ?5")?; + let mut starts = stmt.query_map(params![workspace_id,ticket_id,assignment_id,after_start,(limit+1) as i64],|r| Ok(server_api::TicketAssignmentStartDiagnostic { operation_id:r.get(0)?,claim_state:r.get(1)?,assignment_id:r.get(2)?,runtime_id:r.get(3)?,worker_id:r.get(4)?,actual_processing_unknown:true }))?.collect::,_>>()?; + let more = starts.len()>limit; + starts.truncate(limit); + let next_start_operation_id = if more { starts.last().map(|i| i.operation_id.clone()) } else { None }; + Ok(server_api::TicketAssignmentDiagnosticsPage { assignment_id:assignment_id.into(),current:assignment.current,input_origin_known:known,actionable,inputs,next_input_event_id,starts,next_start_operation_id }) + }) +} diff --git a/crates/workspace-server/src/assignment_delivery_tests.rs b/crates/workspace-server/src/assignment_delivery_tests.rs index 7a93d3c12..b8702edb7 100644 --- a/crates/workspace-server/src/assignment_delivery_tests.rs +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -11,6 +11,27 @@ fn delivery_store_external_event( }).unwrap(); } +fn delivery_store_answer(store: &SqliteWorkspaceStore, key: &str) { + let current = store + .list_current_ticket_assignment_tracking("w", "tracking") + .unwrap() + .remove(0); + store + .answer_ticket_assignment( + "w", + "tracking", + &ticket::TicketAssignmentAnswer { + assignment_id: current.assignment_id, + expected_last_event_id: current.last_event_id.unwrap(), + operation_key: key.into(), + body: "Explicit continuing answer".into(), + }, + &assignment_answer_source(), + "fixture-actor", + ) + .unwrap(); +} + fn delivery_store_reserve_initial( store: &SqliteWorkspaceStore, source: &TicketAssignmentTrackingSourceContext, @@ -60,11 +81,18 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a Some("assignment_tracking"), ); delivery_store_external_event(&store, 2, "comment", None); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); + delivery_store_answer(&store, "answer-after-comment"); let pending = store .list_pending_ticket_assignment_deliveries("w", 100) .unwrap(); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].input_event_id, "ticket-event:2"); + assert_eq!(pending[0].input_event_id, "ticket-event:3"); assert_eq!( pending[0].waiting_reason.as_deref(), Some("Waiting for owner answer") @@ -74,7 +102,7 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a Some("Read answer and decide") ); let answer = store - .reserve_ticket_assignment_delivery("w", "a", "ticket-event:2", &source) + .reserve_ticket_assignment_delivery("w", "a", "ticket-event:3", &source) .unwrap(); store .ack_ticket_assignment_delivery("w", &answer.delivery_id, &source) @@ -85,16 +113,16 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a .unwrap() .is_empty() ); - // Typed extension inputs must not disappear merely because they are not - // comments; own tracking/history events remain excluded. - delivery_store_external_event(&store, 3, "assignment_tracking_updated", None); - delivery_store_external_event(&store, 4, "history", None); - delivery_store_external_event(&store, 5, "owner_answer", None); + // Explicit fenced answers remain inputs; own tracking/history and ordinary + // comments remain excluded. + delivery_store_external_event(&store, 4, "assignment_tracking_updated", None); + delivery_store_external_event(&store, 5, "history", None); + delivery_store_answer(&store, "next-explicit-answer"); let pending = store .list_pending_ticket_assignment_deliveries("w", 100) .unwrap(); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].input_event_id, "ticket-event:5"); + assert_eq!(pending[0].input_event_id, "ticket-event:6"); let saved = store .get_ticket_assignment_tracking("w", "tracking", "a") .unwrap() @@ -792,3 +820,216 @@ async fn assignment_startup_unfinished_dependencies_remain_diagnostics_not_store }) .unwrap(); } + +#[tokio::test] +async fn assignment_diagnostics_keep_persisted_notification_acceptance_and_history_distinct_without_payload() + { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let initial = store + .list_pending_ticket_assignment_deliveries("w", 10) + .unwrap() + .remove(0); + let page = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert!(page.current && page.actionable && page.input_origin_known); + assert_eq!( + page.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::NotReserved + ); + let initial = store + .reserve_ticket_assignment_delivery( + "w", + "a", + &initial.input_event_id, + &tracking_store_source(), + ) + .unwrap(); + assert_eq!( + store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap() + .inputs[0] + .notification, + server_api::TicketAssignmentInputDeliveryState::Reserved + ); + store + .prepare_ticket_assignment_delivery_content( + "w", + &initial.delivery_id, + "SECRET RENDERED PAYLOAD", + &tracking_store_source(), + ) + .unwrap(); + let pending = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!( + pending.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::RequestPersisted + ); + assert!(pending.inputs[0].acceptance_outcome_unknown); + store + .ack_ticket_assignment_delivery("w", &initial.delivery_id, &tracking_store_source()) + .unwrap(); + delivery_store_answer(&store, "diagnostic-answer"); + delivery_store_external_event(&store, 1, "history", None); + let accepted = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!( + accepted.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::RuntimeAccepted + ); + assert!(!accepted.inputs[0].acceptance_outcome_unknown); + assert_eq!( + accepted.next_input_event_id.as_deref(), + Some(initial.input_event_id.as_str()) + ); + let next = store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + accepted.next_input_event_id.as_deref(), + None, + 1, + ) + .unwrap(); + assert_eq!(next.inputs[0].input_event_id, "ticket-event:0"); + assert!(next.next_input_event_id.is_none()); + let wire = serde_json::to_string(&accepted).unwrap(); + for forbidden in [ + "SECRET", + "tracking-binding", + "authenticated-worker", + "payload_json", + "source_json", + "content", + ] { + assert!(!wire.contains(forbidden), "{wire}"); + } + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("a", "event-assign-a", "done"), + &tracking_store_source(), + ) + .unwrap(); + let history = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100) + .unwrap(); + assert!(history.current && !history.actionable); + assert_eq!(history.inputs.len(), 2); // retained inputs are history, not fresh work + assert_eq!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + accepted.next_input_event_id.as_deref(), + None, + 100 + ) + .unwrap() + .inputs[0] + .input_event_id, + "ticket-event:0" + ); + assert!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + Some("ticket-event:999"), + None, + 1 + ) + .is_err() + ); + assert!( + store + .page_ticket_assignment_diagnostics("other", "tracking", "a", None, None, 1) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_diagnostics_unknown_retained_relationship_does_not_invent_input_or_acceptance() +{ + let (_dir, store) = tracking_store_fixture().await; + store.with_conn(|conn| { + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','legacy','past')",[])?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,tracking_updated_at=NULL,tracking_migration_diagnostic='Unknown historical relationship' WHERE assignment_id='legacy-unknown'",[])?; + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','past')",[])?; + Ok(()) + }).unwrap(); + delivery_store_external_event(&store, 0, "comment", None); + let page = store + .page_ticket_assignment_diagnostics("w", "tracking", "legacy-unknown", None, None, 100) + .unwrap(); + assert!(page.current); + assert!(!page.actionable && !page.input_origin_known); + assert!(page.inputs.is_empty() && page.starts.is_empty()); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_causality_or_processing_inference() + { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + delivery_store_start_reserve(&store, "z-first-start", "a", &source).unwrap(); + store + .fail_ticket_assignment_operation("w", "z-first-start", "External resource refused start") + .unwrap(); + delivery_store_start_reserve(&store, "a-second-start", "a", &source).unwrap(); + let first = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!(first.starts.len(), 1); + assert_eq!(first.starts[0].operation_id, "z-first-start"); + assert_eq!(first.starts[0].claim_state, "failed"); + assert!(first.starts[0].actual_processing_unknown); + assert_eq!( + first.next_start_operation_id.as_deref(), + Some("z-first-start") + ); + let second = store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + None, + first.next_start_operation_id.as_deref(), + 1, + ) + .unwrap(); + assert_eq!(second.starts[0].operation_id, "a-second-start"); + assert_eq!(second.starts[0].claim_state, "pending"); + assert!(second.starts[0].actual_processing_unknown); + assert!(second.next_start_operation_id.is_none()); + assert!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + None, + Some("foreign-operation"), + 1 + ) + .is_err() + ); + let wire = serde_json::to_string(&first).unwrap(); + assert!(!wire.contains("tracking-binding") && !wire.contains("External resource")); +} diff --git a/crates/workspace-server/src/assignment_tracking.rs b/crates/workspace-server/src/assignment_tracking.rs index c798df247..f1cfa808e 100644 --- a/crates/workspace-server/src/assignment_tracking.rs +++ b/crates/workspace-server/src/assignment_tracking.rs @@ -171,3 +171,45 @@ pub(super) fn history_page( }) }) } + +pub(super) fn assignments_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result { + store.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + ticket::sqlite_ticket_assignments_page( + &tx, + workspace_id, + ticket_id, + after_assignment_id, + limit, + ) + .map_err(|e| Error::InvalidInput(e.to_string())) + }) +} + +pub(super) fn answer( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, +) -> Result { + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_assignment_source(&tx,workspace_id,source)?; + // Replay retains its saved result after assignment replacement; new input + // must still be admitted by the exact current operational delegation. + let replay: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2)",params![workspace_id,input.operation_key],|r| r.get(0))?; + if !replay { assignment_delivery::require_current(&tx,workspace_id,ticket_id,&input.assignment_id)?; } + let source = serde_json::to_value(source).map_err(|e| Error::Store(e.to_string()))?; + let result = ticket::sqlite_answer_ticket_assignment_in_transaction(&tx,workspace_id,ticket_id,input,&source,actor).map_err(|e| Error::TicketAssignmentConflict(e.to_string()))?; + tx.commit()?; + Ok(result) + }) +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 7c85c4812..513daef26 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -458,7 +458,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip vec![ (91, "workspace schema baseline".into()), (92, "Ticket-owned assignment tracking".into()), - (93, "Durable assignment deliveries and source-bound startup fences".into()) + ( + 93, + "Durable assignment deliveries and source-bound startup fences".into() + ) ] ); for (table, rows) in retained.iter().zip(original.iter()) { @@ -1485,3 +1488,337 @@ async fn orchestrator_clear_receipt_compares_nullable_reason_as_full_payload_not .unwrap() ); } + +fn assignment_answer_input(key: &str) -> ticket::TicketAssignmentAnswer { + ticket::TicketAssignmentAnswer { + assignment_id: "answer-a".into(), + expected_last_event_id: "event-answer-assign".into(), + operation_key: key.into(), + body: "Proceed with option B".into(), + } +} +fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} + +#[tokio::test] +async fn assignment_answer_replay_returns_saved_event_without_new_assignment_or_status_change() { + let (dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let input = assignment_answer_input("answer-key"); + let before = store + .get_ticket_assignment_tracking("w", "tracking", "answer-a") + .unwrap() + .unwrap(); + let saved = store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "authenticated-worker:r/worker", + ) + .unwrap(); + drop(store); + let store = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + assert_eq!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .unwrap(), + saved + ); + assert_eq!( + store + .get_ticket_assignment_tracking("w", "tracking", "answer-a") + .unwrap() + .unwrap(), + before + ); + let mut changed = input.clone(); + changed.body = "Different answer".into(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &changed, + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .is_err() + ); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(pending.len(), 2); + assert!( + pending + .iter() + .any(|p| p.input_event_id == saved.input_event_id) + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'", + [], + |r| r.get::<_, i64>(0) + )?, + 1 + ); + assert_eq!( + conn.query_row( + "SELECT value FROM typed_ticket_event_attributes WHERE key='operation_key'", + [], + |r| r.get::<_, String>(0) + )?, + "answer-key" + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_answer_receipt_failure_rolls_back_body_audit_and_ticket_touch() { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + store.with_conn(|conn| { + conn.execute_batch("CREATE TRIGGER reject_answer_receipt BEFORE INSERT ON typed_ticket_assignment_tracking_operations BEGIN SELECT RAISE(ABORT,'receipt failed'); END;")?; + Ok(()) + }).unwrap(); + let before: String = store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'", + [], + |r| r.get(0), + )?) + }) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("failed-key"), + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .is_err() + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_event_attributes WHERE key='operation_key'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + assert_eq!( + conn.query_row( + "SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'", + [], + |r| r.get::<_, String>(0) + )?, + before + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_exact_retry_is_saved() +{ + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let input = assignment_answer_input("saved-answer"); + let saved = store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor", + ) + .unwrap(); + let mut update = tracking_store_input("answer-a", "event-answer-assign", "next-status"); + update.tracking.status = TicketAssignmentTrackingStatus::Waiting; + store + .update_ticket_assignment_tracking("w", "tracking", &update, &tracking_store_source()) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("stale-answer"), + &assignment_answer_source(), + "actor" + ) + .is_err() + ); + let mut replacement = manual_worker_assignment("tracking", "answer-b"); + replacement.role = TicketAssignmentRole::Orchestrator; + replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }; + store + .set_current_ticket_role_assignment( + &replacement, + Some("answer-a"), + "replace-answer", + "replace-answer-key", + true, + ) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("replaced-answer"), + &assignment_answer_source(), + "actor" + ) + .is_err() + ); + assert_eq!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor" + ) + .unwrap(), + saved + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", + [], + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor" + ) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_identity_pages_keep_release_and_last_status_separate_and_reject_foreign_cursor() +{ + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "z-first", "first"); + let mut replacement = manual_worker_assignment("tracking", "a-second"); + replacement.role = TicketAssignmentRole::Orchestrator; + replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }; + store + .set_current_ticket_role_assignment( + &replacement, + Some("z-first"), + "replacement-event", + "replacement-key", + true, + ) + .unwrap(); + let page = store + .page_ticket_assignments("w", "tracking", None, 1) + .unwrap(); + assert_eq!(page.next_assignment_id.as_deref(), Some("z-first")); + assert!(!page.items[0].current); + assert_eq!( + page.items[0].tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert_eq!( + page.items[0].release.as_ref().unwrap().action, + ticket::TicketAssignmentReleaseAction::Replaced + ); + let next = store + .page_ticket_assignments("w", "tracking", page.next_assignment_id.as_deref(), 1) + .unwrap(); + assert_eq!(next.items[0].assignment_id, "a-second"); + assert!(next.items[0].current); + assert!(next.next_assignment_id.is_none()); + assert!( + store + .page_ticket_assignments("other", "tracking", Some("z-first"), 1) + .is_err() + ); + assert!( + store + .page_ticket_assignments("w", "tracking", None, 101) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_answer_concurrent_exact_retry_on_two_connections_creates_one_event_and_receipt() +{ + let (dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let path = dir.path().join("workspace.db"); + let first = SqliteWorkspaceStore::open(&path).unwrap(); + let second = SqliteWorkspaceStore::open(&path).unwrap(); + let barrier = std::sync::Barrier::new(2); + let results = std::thread::scope(|scope| { + let run = |store: &SqliteWorkspaceStore| { + barrier.wait(); + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("concurrent-answer"), + &assignment_answer_source(), + "actor", + ) + .unwrap() + }; + let a = scope.spawn(move || run(&first)); + let b = scope.spawn(move || run(&second)); + (a.join().unwrap(), b.join().unwrap()) + }); + assert_eq!(results.0, results.1); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'",[],|r| r.get::<_,i64>(0))?,1); + assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_assignment_tracking_operations WHERE operation_key='concurrent-answer'",[],|r| r.get::<_,i64>(0))?,1); + Ok(()) + }).unwrap(); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index abda3e7b2..1203f2503 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -405,6 +405,54 @@ impl SqliteWorkspaceAuthority { .collect::>(); predicates.push(format!("t.workflow_state IN ({})", states.join(","))); } + if let Some(reason) = &query.closure_reason { + let encoded = + serde_json::to_string(reason).map_err(|e| Error::InvalidInput(e.to_string()))?; + let value = bind(SqlValue::Text(encoded)); + predicates.push(format!("t.workflow_state='closed' AND EXISTS (SELECT 1 FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id AND json_extract(h.judgment_json,'$.action.reason')=json_extract({value},'$'))")); + } + if query.assigned_principal.is_some() || query.handling_status.is_some() { + let mut assignment = vec![ + "a.workspace_id=t.workspace_id".to_string(), + "a.ticket_id=t.ticket_id".to_string(), + ]; + if let Some(principal) = &query.assigned_principal { + match principal { + ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => { + let value = bind(SqlValue::Text(agent_key.clone())); + assignment.push(format!( + "a.principal_kind='workspace_agent' AND a.principal_id={value}" + )); + } + ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => { + let value = bind(SqlValue::Text(account_id.clone())); + assignment.push(format!( + "a.principal_kind='user' AND a.principal_id={value}" + )); + } + ticket::TicketAssignmentPrincipalSnapshot::Worker { + runtime_id, + worker_id, + } => { + let runtime = bind(SqlValue::Text(runtime_id.clone())); + let worker = bind(SqlValue::Text(worker_id.clone())); + assignment.push(format!("a.principal_kind='worker' AND a.runtime_id={runtime} AND a.worker_id={worker}")); + } + } + } + if let Some(status) = &query.handling_status { + let value = match status { + ticket::TicketAssignmentTrackingStatus::Waiting => "waiting", + ticket::TicketAssignmentTrackingStatus::Inprogress => "inprogress", + ticket::TicketAssignmentTrackingStatus::Done => "done", + }; + let value = bind(SqlValue::Text(value.into())); + assignment.push(format!( + "a.role='orchestrator' AND json_extract(a.tracking_json,'$.status')={value}" + )); + } + predicates.push(format!("EXISTS (SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id WHERE {})",assignment.join(" AND "))); + } for id in &query.tag_ids { let id = bind(SqlValue::Text(id.clone())); predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)")); @@ -426,7 +474,9 @@ impl SqliteWorkspaceAuthority { "(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS ( SELECT 1 FROM typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id - AND lower(e.body) LIKE {p}))", + AND lower(e.body) LIKE {p}) OR EXISTS ( + SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id + AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))", p = pattern )); } @@ -1121,6 +1171,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let summary = ticket_summary_from_ticket(&authoritative)?; let authoritative_body = authoritative.document.body.clone(); let authoritative_events = authoritative.events.clone(); + let authoritative_case_history = authoritative.case_history.clone(); let detail = self.ticket_detail_from_ticket( authoritative, TicketShowRequest { @@ -1134,6 +1185,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { &detail, authoritative_body.as_str(), &authoritative_events, + &authoritative_case_history, &query, ); let item = ticket_query_item( @@ -1141,6 +1193,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { &detail, authoritative_body.as_str(), &authoritative_events, + &authoritative_case_history, &query, ); last_scanned_cursor = Some(make_ticket_cursor(&item, sort, &fingerprint)); @@ -1993,6 +2046,33 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + if let Some(ticket::TicketClosureReason::Other(text)) = &query.closure_reason + && (text.trim().is_empty() || text.len() > 1024 || text.contains('\0')) + { + return Err(Error::InvalidInput( + "Closure reason Other must be nonblank and at most 1024 bytes without NUL".into(), + )); + } + if let Some(principal) = &query.assigned_principal { + let ids: Vec<&str> = match principal { + ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id], + ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => { + vec![agent_key] + } + ticket::TicketAssignmentPrincipalSnapshot::Worker { + runtime_id, + worker_id, + } => vec![runtime_id, worker_id], + }; + if ids + .iter() + .any(|id| id.trim().is_empty() || id.len() > 1024 || id.chars().any(char::is_control)) + { + return Err(Error::InvalidInput( + "Assigned principal requires bounded stable identities".into(), + )); + } + } for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] { if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) { return Err(Error::InvalidInput( @@ -2181,11 +2261,27 @@ fn ticket_attention_matches( } } +fn closure_reason_text(action: &ticket::TicketCaseAction) -> Option { + let reason = match action { + ticket::TicketCaseAction::Close { reason } + | ticket::TicketCaseAction::CorrectClosure { reason } => reason, + ticket::TicketCaseAction::Reopen => return None, + }; + Some(match reason { + ticket::TicketClosureReason::Completed => "completed".into(), + ticket::TicketClosureReason::NotPlanned => "not_planned".into(), + ticket::TicketClosureReason::Duplicate => "duplicate".into(), + ticket::TicketClosureReason::Unknown => "unknown".into(), + ticket::TicketClosureReason::Other(text) => format!("other {text}"), + }) +} + fn ticket_matches_query( summary: &TicketSummary, detail: &TicketDetail, authoritative_body: &str, authoritative_events: &[TicketEvent], + authoritative_case_history: &[ticket::TicketCaseHistoryEntry], query: &TicketQueryRequest, ) -> bool { let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id); @@ -2311,7 +2407,10 @@ fn ticket_matches_query( } query.query.as_ref().is_none_or(|text| { let needle = text.to_lowercase(); - summary.title.to_lowercase().contains(&needle) + authoritative_case_history.iter().any(|entry| { + closure_reason_text(&entry.action) + .is_some_and(|reason| reason.to_lowercase().contains(&needle)) + }) || summary.title.to_lowercase().contains(&needle) || authoritative_body.to_lowercase().contains(&needle) || authoritative_events .iter() @@ -2324,6 +2423,7 @@ fn ticket_query_item( detail: &TicketDetail, authoritative_body: &str, authoritative_events: &[TicketEvent], + authoritative_case_history: &[ticket::TicketCaseHistoryEntry], query: &TicketQueryRequest, ) -> TicketQueryItem { let mut matched_fields = Vec::new(); @@ -2331,6 +2431,14 @@ fn ticket_query_item( let mut matching_event = None; if let Some(text) = query.query.as_ref() { let needle = text.to_lowercase(); + if let Some(reason) = authoritative_case_history + .iter() + .filter_map(|entry| closure_reason_text(&entry.action)) + .find(|reason| reason.to_lowercase().contains(&needle)) + { + matched_fields.push("closure_reason".into()); + snippet = Some(reason); + } if summary.title.to_lowercase().contains(&needle) { matched_fields.push("title".to_string()); snippet = Some(summary.title.clone()); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5e5742bb5..5ed22bcc8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3097,7 +3097,9 @@ fn is_server_global_forward(path: &str) -> bool { } fn is_server_static_forward(path: &str) -> bool { - !path.starts_with("/api/") && !path.starts_with("/internal/") + !path.starts_with("/api/") + && !path.starts_with("/internal/") + && canonical_ticket_operation_workspace_id(path).is_none() } async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response { @@ -3107,7 +3109,7 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } -// Canonical case/tracking routes are deliberately not /api/w aliases. Recognize +// Canonical Ticket operation routes are deliberately not /api/w aliases. Recognize // only these operations so authentication/scope are identical without exposing CRUD. fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> { let mut segments = path.strip_prefix("/workspaces/")?.split('/'); @@ -3119,10 +3121,10 @@ fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> { && resource == "tickets" && !ticket_id.is_empty() && match operation { - "case" => segments.next().is_none(), + "case" | "assignment-diagnostics" | "assignment-answer" => segments.next().is_none(), "assignment-tracking" => match segments.next() { None => true, - Some("history") => segments.next().is_none(), + Some("history" | "assignments") => segments.next().is_none(), _ => false, }, _ => false, @@ -6614,6 +6616,18 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_case_update( service.clone(), )) + .merge(server_api::server_api_axum::ticket_assignments_page( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignments_page_browser(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_diagnostics( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_diagnostics_browser(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_answer( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_answer_browser(service.clone())) .merge(server_api::server_api_axum::ticket_assignment_tracking_get( service.clone(), )) @@ -10566,6 +10580,132 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_assignments_page( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + ) -> std::result::Result + { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + api.store + .page_ticket_assignments( + &workspace_id, + &ticket_id, + query.after_assignment_id.as_deref(), + query.limit.unwrap_or(30), + ) + .map(server_api::TicketAssignmentsPage) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error) + } + async fn ticket_assignments_page_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + ) -> std::result::Result + { + self.ticket_assignments_page(context, workspace_id, ticket_id, query) + .await + } + async fn ticket_assignment_diagnostics( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + ) -> std::result::Result< + server_api::TicketAssignmentDiagnosticsPage, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + api.store + .page_ticket_assignment_diagnostics( + &workspace_id, + &ticket_id, + &query.assignment_id, + query.after_input_event_id.as_deref(), + query.after_start_operation_id.as_deref(), + query.limit.unwrap_or(30), + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error) + } + async fn ticket_assignment_diagnostics_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + ) -> std::result::Result< + server_api::TicketAssignmentDiagnosticsPage, + server_api::RepositoryApiError, + > { + self.ticket_assignment_diagnostics(context, workspace_id, ticket_id, query) + .await + } + async fn ticket_assignment_answer( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + ) -> std::result::Result< + server_api::TicketAssignmentAnswerResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let (actor, source) = + authenticated_assignment_source(api, &contract_request_headers(&context)?, &context) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let receipt = api + .store + .answer_ticket_assignment(&workspace_id, &ticket_id, &request.0, &source, &actor) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + // The entire event/audit/receipt transaction has committed. Response loss + // retries the saved receipt; delivery scan deduplicates accepted inputs. + if let Some(orchestrator) = find_workspace_orchestrator(api) { + dispatch_orchestrator_assignment_attention(api, &orchestrator.worker); + } + Ok(server_api::TicketAssignmentAnswerResponse(receipt)) + } + async fn ticket_assignment_answer_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + ) -> std::result::Result< + server_api::TicketAssignmentAnswerResponse, + server_api::RepositoryApiError, + > { + self.ticket_assignment_answer(context, workspace_id, ticket_id, request) + .await + } + async fn ticket_assignment_tracking_get( &self, _context: server_api::ServerRequestContext, @@ -48195,6 +48335,42 @@ mod tests { } } + async fn author_notification_test_answer(api: &WorkspaceApi, ticket_id: &str, key: &str) { + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_id) + .unwrap() + .remove(0); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_id}/assignment-answer" + )) + .header( + "authorization", + format!("Bearer api-token-{TEST_WORKSPACE_ID}"), + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "assignment_id": current.assignment_id, + "expected_last_event_id": current.last_event_id.unwrap(), + "operation_key": key, + "body": "Explicit continuing input" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + } + #[tokio::test] async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() { let dir = tempfile::tempdir().unwrap(); @@ -48350,8 +48526,15 @@ mod tests { .unwrap(); } + assert_eq!( + execution.take_inputs().len(), + 1, + "ordinary history must not create unfenced continuing input" + ); + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + author_notification_test_answer(&api, &ticket.id, "notification-explicit-answer").await; let inputs = execution.take_inputs(); - assert_eq!(inputs.len(), 4, "Assign input plus three external events"); + assert_eq!(inputs.len(), 1, "one fenced continuing answer"); let unique = inputs .iter() .map(|(_, content)| content.clone()) @@ -50357,11 +50540,13 @@ mod tests { assert!(!serialized.contains("queued update")); assert!(!serialized.contains("Ticket notification:")); + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + author_notification_test_answer(&api, &ticket_ref.id, "notification-saved-answer").await; let notifications = execution.take_inputs(); assert_eq!( notifications.len(), 2, - "initial delegation plus external comment" + "initial delegation plus fenced answer; ordinary comment is history" ); assert_eq!( notifications[0].0.worker_id.to_string(), @@ -50406,6 +50591,7 @@ mod tests { api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(), TicketState::Open.as_str() ); + author_notification_test_answer(&api, &ticket_ref.id, "notification-failed-answer").await; let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index d72a8e735..7f8c57d56 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -309,7 +309,11 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { "existing" ); let retry = execution.take_inputs(); - assert_eq!(retry.len(), 2, "acknowledged retry must also drain the changed backlog"); + assert_eq!( + retry.len(), + 2, + "acknowledged retry must also drain the changed backlog" + ); assert_eq!(retry[0].1, pending.content); assert!(retry[1].1.contains("Changed backlog")); let ids = execution.take_input_request_ids(); @@ -760,11 +764,27 @@ async fn real_attention_worker_start_restore(lose_response: bool) { assert_eq!(receipts().len(), 1); assert_eq!(commits(), 1); // Only a new external input earns another delivery, not restore itself. - browser_ticket_backend(&api) + seed_test_api_token(api.store.as_ref(), "attention-answer"); + let observed = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id) .unwrap() - .add_event( - ticket.id.clone().into(), - ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "New owner answer"), + .remove(0); + api.store + .answer_ticket_assignment( + TEST_WORKSPACE_ID, + &ticket.id, + &ticket::TicketAssignmentAnswer { + assignment_id: observed.assignment_id, + expected_last_event_id: observed.last_event_id.unwrap(), + operation_key: "attention-owner-answer".into(), + body: "New owner answer".into(), + }, + &crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: "account-attention-answer".into(), + user_id: "user-attention-answer".into(), + }, + "fixture-account", ) .unwrap(); maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle); diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 489674c19..09a50e532 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -163,6 +163,41 @@ async fn delivery_self_report( serde_json::from_slice(&bytes).unwrap() } +async fn delivery_external_answer(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) { + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket) + .unwrap() + .remove(0); + delivery_http(api,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"),Some(json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":key,"body":text})),StatusCode::OK).await; +} + +fn delivery_commit_answer_without_scan(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) { + seed_test_api_token(api.store.as_ref(), "direct-answer"); + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket) + .unwrap() + .remove(0); + api.store + .answer_ticket_assignment( + TEST_WORKSPACE_ID, + ticket, + &ticket::TicketAssignmentAnswer { + assignment_id: current.assignment_id, + expected_last_event_id: current.last_event_id.unwrap(), + operation_key: key.into(), + body: text.into(), + }, + &crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: "account-direct-answer".into(), + user_id: "user-direct-answer".into(), + }, + "fixture-account", + ) + .unwrap(); +} + fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) { browser_ticket_backend(api) .unwrap() @@ -212,7 +247,18 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del ); let waiting = delivery_tracking(&api, &ticket.id, assignment); assert_eq!(serde_json::to_value(&waiting).unwrap(), reported); - delivery_external_comment(&api, &ticket.id, "Human answer: choose option B"); + delivery_external_comment(&api, &ticket.id, "History-only annotation while waiting"); + assert!( + execution.take_inputs().is_empty(), + "ordinary waiting comment became fresh work" + ); + delivery_external_answer( + &api, + &ticket.id, + "Human answer: choose option B", + "choose-B", + ) + .await; let answer = execution.take_inputs(); assert_eq!( answer.len(), @@ -369,7 +415,10 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop assert!(inputs[0].1.contains(new_assignment)); let ids = execution.take_input_request_ids(); assert_eq!(ids.len(), 1); - assert_ne!(ids[0], old_ids[0], "new assignment reused stale input identity"); + assert_ne!( + ids[0], old_ids[0], + "new assignment reused stale input identity" + ); assert_eq!( delivery_assign(&api, &ticket.id, "assign-old").await, assigned @@ -404,7 +453,8 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a &ticket.id, assigned["assignment"]["assignment_id"].as_str().unwrap(), ); - delivery_self_report(&api, &identity, &worker, ¤t, "waiting", "wait-answer").await; + delivery_self_report(&api, &identity, &worker, ¤t, "waiting", "wait-answer") + .await; execution.take_inputs(); execution.take_input_request_ids(); } @@ -422,29 +472,33 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a true, |state, pending| { check_orchestrator_assignment_attention( - &caller_api, &caller_worker, state, pending, + &caller_api, + &caller_worker, + state, + pending, ); if checks == 0 { // The production scan has read an empty snapshot but // still owns its reservation. Race a real HTTP commit. entered_tx.send(()).unwrap(); - release_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); } checks += 1; }, ); assert_eq!(checks, 2, "committed input must defer a scan"); }); - entered_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); if answer { - futures::executor::block_on(delivery_http( + futures::executor::block_on(delivery_external_answer( &api, - "POST", - &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/thread-events", ticket.id), - Some(serde_json::to_value(NewTicketEvent::new( - TicketEventKind::Comment, "Human answer: go ahead", - )).unwrap()), - StatusCode::NO_CONTENT, + &ticket.id, + "Human answer: go ahead", + "racing-answer", )); } else { futures::executor::block_on(delivery_assign(&api, &ticket.id, "racing-assign")); @@ -456,7 +510,11 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a }); let inputs = execution.take_inputs(); assert_eq!(inputs.len(), 1, "idle Orchestrator was stranded"); - assert!(inputs[0].1.contains(ticket.resource_key.as_deref().unwrap())); + assert!( + inputs[0] + .1 + .contains(ticket.resource_key.as_deref().unwrap()) + ); if answer { assert!(inputs[0].1.contains("Need the human's choice")); } @@ -476,15 +534,26 @@ async fn assignment_failed_delivery_and_its_reentrant_idle_do_not_schedule_retry execution.before_input_returns(move |worker| { use protocol::subscription::SubscriptionWorkerState as S; maybe_dispatch_orchestrator_turn_end( - &callback_api, &worker.worker_id.to_string(), Some(S::Running), S::Idle, + &callback_api, + &worker.worker_id.to_string(), + Some(S::Running), + S::Idle, ); }); delivery_assign(&api, &ticket.id, "assign-failed").await; - assert_eq!(execution.take_inputs().len(), 2, "idle must not add retries beyond the bounded send"); + assert_eq!( + execution.take_inputs().len(), + 2, + "idle must not add retries beyond the bounded send" + ); let ids = execution.take_input_request_ids(); assert_eq!(ids.len(), 2); assert_eq!(ids[0], ids[1]); - assert_eq!(attention_pending(&api).len(), 1, "failure must retain durable input"); + assert_eq!( + attention_pending(&api).len(), + 1, + "failure must retain durable input" + ); delivery_stop(&api, &worker); } @@ -796,8 +865,22 @@ async fn delivery_wait_idle( "duplicate canonical delivery: {:?}", delivery_commits(api, worker) ); + let entries = delivery_session_entries(api, worker); + let last_notification = entries.iter().rposition(|entry| matches!( + entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, session_store::SystemItem::Notification { .. }) + )); + let last_completed = entries.iter().rposition(|entry| matches!(entry, LogEntry::RunCompleted { .. })); + // Runtime acceptance and the saved notification precede processing. + // Initial Idle can therefore be observed before any model request. + // Require a committed run outcome after the latest input, not a + // guessed delay or Worker liveness as proof of consumption. + let processed = last_notification.zip(last_completed) + .is_some_and(|(input, completed)| completed > input); let summary = api.runtime.worker(worker).unwrap(); if commits == expected_commits + && processed && summary .worker_state .as_ref() @@ -962,16 +1045,12 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi waiting ); let before = execution.attempts.lock().unwrap().len(); - browser_ticket_backend(&reopened) - .unwrap() - .add_event( - ticket.id.clone().into(), - ticket::NewTicketEvent::new( - ticket::TicketEventKind::Comment, - "External answer after server restart", - ), - ) - .unwrap(); + delivery_commit_answer_without_scan( + &reopened, + &ticket.id, + "External answer after server restart", + "answer-after-restart", + ); let barrier = Arc::new(std::sync::Barrier::new(8)); std::thread::scope(|scope| { let joins = (0..8) diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs index 7ad464549..5c315a5d1 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -68,9 +68,13 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ })).unwrap(); tracking_response( app.clone(), - Request::builder().method("PUT").uri(&worker_path).header(CONTENT_TYPE, "application/json") + Request::builder() + .method("PUT") + .uri(&worker_path) + .header(CONTENT_TYPE, "application/json") .header("authorization", format!("Bearer {token}")) - .body(Body::from(worker_input)).unwrap(), + .body(Body::from(worker_input)) + .unwrap(), StatusCode::OK, ) .await; @@ -127,7 +131,10 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ StatusCode::OK, ) .await; - assert_ne!(fresh["assignment"]["assignment_id"].as_str(), Some(assignment)); + assert_ne!( + fresh["assignment"]["assignment_id"].as_str(), + Some(assignment) + ); assert_eq!( api.store .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment) @@ -146,9 +153,18 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ .unwrap(), active ); - assert_eq!(api.store.get_current_ticket_role_assignment(TEST_WORKSPACE_ID, &ticket, - TicketAssignmentRole::Orchestrator).unwrap().unwrap().assignment_id, - fresh["assignment"]["assignment_id"].as_str().unwrap()); + assert_eq!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .unwrap() + .assignment_id, + fresh["assignment"]["assignment_id"].as_str().unwrap() + ); } } @@ -1494,3 +1510,550 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol .is_empty() ); } + +#[tokio::test] +async fn public_assignment_answer_authenticates_source_replays_saved_result_and_rejects_read_reassign_race() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let token = seed_test_api_token(api.store.as_ref(), "answer-owner"); + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"); + let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"}); + let account_request = |body: &serde_json::Value| { + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() + }; + tracking_response( + app.clone(), + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let saved = tracking_response(app.clone(), account_request(&body), StatusCode::OK).await; + assert_eq!(saved["assignment_id"], current.assignment_id); + let backend = browser_ticket_backend(&api).unwrap(); + let authored = backend.show(ticket.clone().into()).unwrap(); + assert_eq!( + authored + .events + .iter() + .filter(|e| e.kind.as_str() == "assignment_answer") + .count(), + 1 + ); + let event = authored + .events + .iter() + .find(|e| e.kind.as_str() == "assignment_answer") + .unwrap(); + assert!( + event + .author + .as_deref() + .unwrap() + .starts_with("workspace-user:") + ); + assert_eq!(event.body.as_str(), "Choose the bounded option"); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); + assert_eq!( + tracking_response(app.clone(), account_request(&body), StatusCode::OK).await, + saved + ); + let mut changed = body.clone(); + changed["body"] = "Changed payload".into(); + tracking_response(app.clone(), account_request(&changed), StatusCode::CONFLICT).await; + let mut spoof = body.clone(); + spoof["actor"] = "owner".into(); + tracking_response( + app.clone(), + account_request(&spoof), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + let assignment_path = + format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await; + let mut stale = body.clone(); + stale["operation_key"] = "fresh-stale-answer".into(); + tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await; + assert_eq!( + tracking_response(app, account_request(&body), StatusCode::OK).await, + saved + ); + assert_eq!( + backend + .show(ticket.into()) + .unwrap() + .events + .iter() + .filter(|e| e.kind.as_str() == "assignment_answer") + .count(), + 1 + ); +} + +#[tokio::test] +async fn public_assignment_pages_and_diagnostics_require_scoped_read_and_reject_foreign_cursors() { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let token = seed_test_api_token(api.store.as_ref(), "diagnostics-reader"); + let app = build_router(api.clone()); + for prefix in [ + format!("/api/w/{TEST_WORKSPACE_ID}"), + format!("/workspaces/{TEST_WORKSPACE_ID}"), + ] { + let path = format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1"); + tracking_response( + app.clone(), + Request::builder().uri(&path).body(Body::empty()).unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let request = |path: String| { + Request::builder() + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + let page = tracking_response(app.clone(), request(path), StatusCode::OK).await; + assert_eq!(page["items"][0]["assignment_id"], current.assignment_id); + assert!(page["next_assignment_id"].is_null()); + let path = format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=1", + current.assignment_id + ); + let diagnostic = + tracking_response(app.clone(), request(path.clone()), StatusCode::OK).await; + assert_eq!(diagnostic["assignment_id"], current.assignment_id); + assert_eq!(diagnostic["input_origin_known"], true); + assert!(diagnostic.get("payload_json").is_none()); + tracking_response( + app.clone(), + request(format!("{path}&after_input_event_id=ticket-event:999")), + StatusCode::BAD_REQUEST, + ) + .await; + tracking_response(app.clone(),request(format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?after_assignment_id=foreign")),StatusCode::BAD_REQUEST).await; + tracking_response( + app.clone(), + request(format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=101", + current.assignment_id + )), + StatusCode::BAD_REQUEST, + ) + .await; + } + let path = format!( + "/api/w/another-workspace/tickets/{ticket}/assignment-diagnostics?assignment_id={}", + current.assignment_id + ); + let response = app + .oneshot( + Request::builder() + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(!response.status().is_success()); +} + +#[tokio::test] +async fn ticket_query_principal_and_handling_filter_before_single_item_page_without_worker_inference() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + tracking_response( + build_router(api.clone()), + signed_tracking_update( + &identity, + Some(&source.worker_id), + &ticket, + &tracking_update(¤t, "query-done"), + ), + StatusCode::OK, + ) + .await; + let backend = browser_ticket_backend(&api).unwrap(); + for n in 0..8 { + backend + .create(ticket::NewTicket::new(format!("Unassigned newer {n}"))) + .unwrap(); + } + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap(); + let found = api.authority.query_tickets(query).unwrap(); + assert_eq!(found.items.len(), 1); + assert_eq!(found.items[0].id, ticket); + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap(); + assert!(api.authority.query_tickets(query).unwrap().items.is_empty()); + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap(); + assert!(api.authority.query_tickets(query).unwrap().items.is_empty()); +} + +#[test] +fn canonical_assignment_operations_are_explicit_scoped_api_routes_not_static_shell_paths() { + for suffix in [ + "case", + "assignment-tracking", + "assignment-tracking/history", + "assignment-tracking/assignments", + "assignment-diagnostics", + "assignment-answer", + ] { + let path = format!("/workspaces/workspace-a/tickets/ticket-a/{suffix}"); + assert_eq!( + canonical_ticket_operation_workspace_id(&path), + Some("workspace-a") + ); + assert_eq!(scoped_workspace_id(&path), Some("workspace-a")); + assert!(!is_server_static_forward(&path)); + assert!(!is_server_global_forward(&path)); + assert!(canonical_ticket_operation_workspace_id(&format!("{path}/extra")).is_none()); + } + for invalid in [ + "/workspaces//tickets/t/assignment-answer", + "/workspaces/w/tickets//assignment-diagnostics", + "/workspaces/w/tickets/t", + "/workspaces/w/tickets/t/unknown", + "/workspaces/w/tickets/t/assignment-tracking/unknown", + "/workspaces/w/workers/t/assignment-answer", + ] { + assert!( + canonical_ticket_operation_workspace_id(invalid).is_none(), + "{invalid}" + ); + } +} + +#[tokio::test] +async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_browser_and_canonical_paths() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + seed_test_api_token(api.store.as_ref(), "assignment-cookie"); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash("assignment-browser-session"), + session_id: "assignment-session".into(), + user_id: "user-assignment-cookie".into(), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &api.config.auth; + let cookie = format!("{cookie_name}=assignment-browser-session"); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"cookie-answer","body":"Authorized browser continuation"}); + let mut receipt = None; + for prefix in [ + format!("/api/w/{TEST_WORKSPACE_ID}"), + format!("/workspaces/{TEST_WORKSPACE_ID}"), + ] { + let identity_path = + format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1"); + let diagnostic_path = format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}", + current.assignment_id + ); + for path in [identity_path, diagnostic_path] { + tracking_response( + app.clone(), + Request::builder().uri(&path).body(Body::empty()).unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + tracking_response( + app.clone(), + Request::builder() + .uri(path) + .header(axum::http::header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + StatusCode::OK, + ) + .await; + } + let answer_path = format!("{prefix}/tickets/{ticket}/assignment-answer"); + let request = |with_origin: bool| { + let mut request = Request::builder() + .method("POST") + .uri(&answer_path) + .header(CONTENT_TYPE, "application/json") + .header(axum::http::header::COOKIE, &cookie); + if with_origin { + request = request.header(ORIGIN, origin); + } + request + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() + }; + tracking_response(app.clone(), request(false), StatusCode::FORBIDDEN).await; + let saved = tracking_response(app.clone(), request(true), StatusCode::OK).await; + if let Some(previous) = &receipt { + assert_eq!(&saved, previous); + } else { + receipt = Some(saved); + } + let cross = answer_path.replace(TEST_WORKSPACE_ID, "foreign-workspace"); + tracking_response( + app.clone(), + Request::builder() + .method("POST") + .uri(cross) + .header(CONTENT_TYPE, "application/json") + .header(axum::http::header::COOKIE, &cookie) + .header(ORIGIN, origin) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::NOT_FOUND, + ) + .await; + } + let events = browser_ticket_backend(&api) + .unwrap() + .show(ticket.into()) + .unwrap() + .events; + let answers = events + .iter() + .filter(|event| event.kind.as_str() == "assignment_answer") + .collect::>(); + assert_eq!(answers.len(), 1); + assert!( + answers[0] + .author + .as_deref() + .unwrap() + .contains("account-assignment-cookie") + ); +} + +#[tokio::test] +async fn canonical_answer_uses_actual_runtime_binding_and_rejects_tampered_source_or_binding_replay() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"); + let input = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"runtime-answer","body":"Runtime-authenticated continuation"}); + let request = || { + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ) + }; + let app = build_router(api.clone()); + let mut unsigned = request(); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + tracking_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + let mut tampered = request(); + *tampered.body_mut() = Body::from("{}"); + tracking_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + let attacker = RuntimeIdentityMaterial::generate(&source.runtime_id).unwrap(); + tracking_response( + app.clone(), + runtime_source_request( + &attacker, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::UNAUTHORIZED, + ) + .await; + tracking_response( + app.clone(), + runtime_source_request( + &identity, + None, + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::FORBIDDEN, + ) + .await; + let saved = tracking_response(app.clone(), request(), StatusCode::OK).await; + assert_eq!( + tracking_response(app.clone(), request(), StatusCode::OK).await, + saved + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let binding:String=conn.query_row("SELECT binding_id FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id],|r| r.get(0)).unwrap(); + let receipt_source:String=conn.query_row("SELECT source_context_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key='runtime-answer'",[TEST_WORKSPACE_ID],|r| r.get(0)).unwrap(); + let receipt_source: serde_json::Value = serde_json::from_str(&receipt_source).unwrap(); + assert_eq!( + receipt_source["source"]["authentication_binding_id"], + binding + ); + assert_eq!(receipt_source["source"]["worker_id"], source.worker_id); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-answer-binding' WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id]).unwrap(); + tracking_response(app.clone(), request(), StatusCode::CONFLICT).await; + let foreign = path.replace(TEST_WORKSPACE_ID, "foreign-workspace"); + let response = app + .oneshot(runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &foreign, + serde_json::to_vec(&input).unwrap(), + )) + .await + .unwrap(); + assert!(!response.status().is_success()); + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .show(ticket.into()) + .unwrap() + .events + .iter() + .filter(|event| event.kind.as_str() == "assignment_answer") + .count(), + 1 + ); +} + +#[tokio::test] +async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_search_retains_old_reasons() + { + let directory = tempfile::tempdir().unwrap(); + let api = test_api(directory.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Reason classification")) + .unwrap(); + let mutate = |key: &str, action: ticket::TicketCaseAction| { + let observed = backend.show(reference.id.clone().into()).unwrap(); + backend + .update_case( + &reference.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&observed), + expected_state: observed.meta.workflow_state, + expected_closure_operation_key: observed.closure_operation_key, + operation_key: key.into(), + action, + explanation: "Case decision explanation, no reason name here".into(), + references: vec![], + author: Some("human".into()), + }, + ) + .unwrap(); + }; + mutate( + "close-not-planned", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + for n in 0..8 { + backend + .create(ticket::NewTicket::new(format!("Newer unrelated {n}"))) + .unwrap(); + } + let query = |value: serde_json::Value| { + api.authority + .query_tickets(serde_json::from_value(value).unwrap()) + .unwrap() + }; + assert_eq!( + query(json!({"closure_reason":"not_planned","limit":1})).items[0].id, + reference.id + ); + mutate( + "correct-duplicate", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Duplicate, + }, + ); + assert!( + query(json!({"closure_reason":"not_planned","limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"closure_reason":"duplicate","limit":1})).items[0].id, + reference.id + ); + assert_eq!( + query(json!({"query":"not_planned","limit":1})).items[0].matched_fields, + vec!["closure_reason"] + ); + mutate("reopen", ticket::TicketCaseAction::Reopen); + assert!( + query(json!({"closure_reason":"duplicate","limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"query":"duplicate","limit":1})).items[0].id, + reference.id + ); + mutate( + "close-custom", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Other("Superseded plan".into()), + }, + ); + assert_eq!( + query(json!({"closure_reason":{"other":"Superseded plan"},"limit":1})).items[0].id, + reference.id + ); + assert!( + query(json!({"closure_reason":{"other":"Other plan"},"limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"query":"Superseded plan","limit":1})).items[0].id, + reference.id + ); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index a709fa672..3e44c9081 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1889,6 +1889,30 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result>; /// Retained responsibility, including ended work and removed principals. /// Use the active variants for admission, routing, authorization or removal. + fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, + ) -> Result; + fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, + ) -> Result; + fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, + ) -> Result; fn update_ticket_assignment_tracking( &self, workspace_id: &str, @@ -7872,6 +7896,50 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_tracking::assignments_page( + self, + workspace_id, + ticket_id, + after_assignment_id, + limit, + ) + } + fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_delivery::diagnostics_page( + self, + workspace_id, + ticket_id, + assignment_id, + after_input_event_id, + after_start_operation_id, + limit, + ) + } + fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, + ) -> Result { + assignment_tracking::answer(self, workspace_id, ticket_id, input, source, actor) + } fn update_ticket_assignment_tracking( &self, workspace_id: &str, diff --git a/docs/design/flow-state-graph.md b/docs/design/flow-state-graph.md index ffada9be8..5d348542c 100644 --- a/docs/design/flow-state-graph.md +++ b/docs/design/flow-state-graph.md @@ -81,7 +81,7 @@ Runtime accepts exactly one Flow segment only when the resolved Profile enables The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Submit request entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments. -When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, the Worker tool derives the assignment operation id from the durable tool-call id rather than accepting lifecycle authority from model input. The shared Workspace worker-create route projects that request into a Coder Ticket-role intent and atomically applies the existing queued-Ticket assignment operation only after Runtime has returned the input-commit acknowledgement. A spawn or pre-commit input failure therefore leaves the Ticket queued and unassigned. +When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, it must also supply its observed current `orchestrator_assignment_id`. The tool derives the start operation identity from durable tool-call context, not model role text. The shared guarded Worker-create path accepts the selected Profile/optional Flow and binds Worker assignment plus resource finalization only after Runtime initial-input commit acknowledgement. Assign is delegation without ready/Queue; unresolved dependencies are diagnostics, not a blanket start gate. Backend fences the exact current delegation and authenticated source across reserve/bind/finalize/recovery. A failed or unknown external outcome must be reconciled from durable operation evidence rather than silently creating a replacement. Start does not update Ticket open/closed or Orchestrator self-reported waiting/inprogress/done. See [delivery/start fences](../development/ticket-assignment-delivery.md). `RequestFlowTransition` accepts only: diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index 14ddee2d8..d109926d6 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -40,7 +40,7 @@ Typical role surfaces are therefore: - Reviewer: item `read` and `review`. - Orchestrator: item `read`, `check_readiness`, and `complete`. -`CompleteTicket` belongs to the Ticket Feature and route-bound Ticket item `complete`, alongside state and close decisions. It takes current item/state CAS, a replay key, reason, and optional references; it requires neither an MR result set nor a Reviewer approval. The former MR collection `complete_ticket` operation is not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run. +`TicketCaseUpdate` belongs to the Ticket Feature and route-bound Ticket item `case_update`, not this MR provider. Close/reopen/correction judgments take current item/state/nullable closure-key CAS, an exact-retry key, typed reason/explanation and optional references; generic closure requires neither an MR result set nor Reviewer approval. Assignment handling done is an independent self-report and does not close the case, integrate an MR, release actual work or stop a Worker. Explicit user approval conditions remain judgment requirements; MR integration still uses its guarded review path. Historical `CompleteTicket` and former MR collection `complete_ticket` operations are not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run. ## Preserved review and integration gates diff --git a/docs/design/orchestrator-worktree-layout.md b/docs/design/orchestrator-worktree-layout.md index ce6f533a4..c14d08539 100644 --- a/docs/design/orchestrator-worktree-layout.md +++ b/docs/design/orchestrator-worktree-layout.md @@ -1,5 +1,7 @@ # Orchestrator worktree and implementation worktree layout +> Historical layout decision and branch report. The Queue/state and mandatory merge→close→cleanup policy below is superseded by [current work operations](../development/work-items.md). Current delegation starts at Assign without ready/Queue; open/closed case judgments, self-reported assignment handling, guarded MR integration, actual work release and Worker/resource cleanup are independent. Respect the requested return path. The original report is preserved below; explicit attached Workdirs and Backend resource authority, not inferred roots, govern current execution. + Yoi can run an Orchestrator from a dedicated orchestration worktree while implementation worktrees still belong under the original workspace root. The runtime must therefore distinguish role execution cwd/workspace from the target repository root used for implementation branches and merge-completion. This decision records the layout policy for Ticket `00001KTTB479X`. diff --git a/docs/design/ticket-evidence.md b/docs/design/ticket-evidence.md index 122399440..637a4b43a 100644 --- a/docs/design/ticket-evidence.md +++ b/docs/design/ticket-evidence.md @@ -1,6 +1,6 @@ # Ticket evidence and immutable Merge Request results -Ticket lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded `done` state or a `CompleteTicket` event. +Ticket open/closed case state, assignment self-reported waiting/inprogress/done, Worker lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded case judgment or handling result. Historical `done` states and `CompleteTicket` events remain readable as history, not current case states or operations. ## Evidence subjects @@ -16,9 +16,9 @@ A merged MR's review status/timestamps describe **integration approval**, not a `approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`. -`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done. +`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because the case is closed or handling is done. -CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item `content_digest`/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts. +`TicketCaseUpdate` records the authenticated actor's close/reopen/correction judgment, typed reason, explanation and optional references using current item `content_digest`, open/closed state and explicitly observed nullable closure operation key CAS, plus an exact-replay operation key. It needs no MR or Reviewer approval and creates neither proof. Explicit user approval conditions still govern judgment; guarded MR integration still requires trusted review. Exact replay never reapplies a historical decision or appends another event; reread current authority before a new operation. Historical `CompleteTicket` and MR-based `ticket_completion` records remain readable and distinct from current case receipts. Handling done is an independent self-report, not closure, Backend certification of fulfillment, MR approval/integration, work release or Worker termination. Classification tag edits do not alter a case judgment. Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used. diff --git a/docs/design/ticket-wip-projection.md b/docs/design/ticket-wip-projection.md index 146e1e193..4d6a2da88 100644 --- a/docs/design/ticket-wip-projection.md +++ b/docs/design/ticket-wip-projection.md @@ -26,7 +26,11 @@ Collection interface `yoi.ticket/collection/v1`: | Native operation | Existing Tool identity | Subject identity | | --- | --- | --- | | `query` | `QueryTicket` | Collection; preserves bounded filters, limit and cursor | -| `create` | `TicketCreate` | Collection; Backend assigns identity | +| `create` | `TicketCreate` | Collection; Backend assigns identity to an open case | +| `tag_list` | `WorkspaceTagList` | Workspace classification; bounded stable-ID pagination | +| `tag_create` | `WorkspaceTagCreate` | Display name and exact-retry operation key | +| `tag_rename` | `WorkspaceTagRename` | Stable tag ID, observed `expected_last_operation_key`, name and operation key | +| `tag_delete` | `WorkspaceTagDelete` | Stable tag ID, observed `expected_last_operation_key` and operation key | Item interface `yoi.ticket/item/v1`: @@ -35,11 +39,16 @@ Item interface `yoi.ticket/item/v1`: | `read` | `ShowTicket` | `event_limit`, `event_cursor` | | `edit` | `TicketEditItem` | title/body replacement and targets edit | | `comment` | `TicketComment` | comment body | -| `mark_ready` | `TicketMarkReady` | optional reason | -| `intake_ready` | `TicketIntakeReady` | intake summary and optional reason | -| `queue` | `TicketQueue` | none | -| `transition` | `TicketWorkflowState` | expected/target state, reason and body | -| `close` | `TicketClose` | resolution | +| `case_update` | `TicketCaseUpdate` | typed close/reopen/correction action, explanation, item/state/nullable closure-key CAS and exact-retry key | +| `assignment_tracking_query` | `TicketAssignmentTrackingQuery` | optional retained `assignment_id` | +| `assignment_tracking_history` | `TicketAssignmentTrackingHistory` | `assignment_id`, bounded limit and event cursor | +| `assignment_tracking_update` | `TicketAssignmentTrackingUpdate` | current `assignment_id`, observed `expected_last_event_id`, operation key and complete self-reported handling | +| `orchestrator_assign` | `TicketOrchestratorAssign` | target principal, explicitly observed nullable `expected_assignment_id`, stable `operation_id`; explicit reissue uses a new operation, exact retry keeps the original | +| `orchestrator_clear` | `TicketOrchestratorClear` | observed `assignment_id`, stable `operation_id` | +| `assignments_query` | `TicketAssignmentsQuery` | bounded limit and `after_assignment_id` cursor for retained rows | +| `assignment_diagnostics` | `TicketAssignmentDiagnostics` | `assignment_id`, bounded limit, independent `after_input_event_id` / `after_start_operation_id` cursors | +| `assignment_answer` | `TicketAssignmentAnswer` | observed same `assignment_id` / `expected_last_event_id`, stable `operation_key`, bounded answer body | +| `tags_set` | `TicketTagsSet` | stable desired `tag_ids`, observed full `expected_tag_ids` set and operation key | | `dependency_check` | `TicketDependencyCheck` | none | | `record_relation` | `TicketRelationRecord` | relation kind, **target Ticket**, optional note | | `remove_relation` | `TicketRelationRemove` | relation kind and **target Ticket** | @@ -50,11 +59,12 @@ The subject `ticket`/`id` parameter is removed from every item descriptor and re Access profiles therefore expose different native interfaces: -- read-only/review: collection `query`; item `read`; -- workspace authoring: query/create plus read/edit/comment/ready/queue/close/relation operations; -- intake: the authoring surface plus `intake_ready`; -- workflow/orchestration: query/read/comment, workflow transition/close/dependency checks, relation operations and orchestration-plan operations; -- work-report: query/read/comment only. +- read-only/review: collection query/tag list; item read, assignment tracking query/history, retained assignments and sanitized diagnostics; +- workspace authoring/intake: query/create, read/edit/comment/case judgment, relation/classification operations, tracking reads, explicit Orchestrator Assign/reissue/clear and continuing answer; +- workflow/orchestration: query/read/comment/case judgment, dependency diagnostics, relation/plan/classification operations, tracking reads/self-report update, explicit Assign/reissue/clear and continuing answer; +- work-report: query/read/comment/case judgment/classification, assignment reads/diagnostics and continuing answer. Actual exposure is additive from manifest flags; authoring-enabled Coder configurations also expose delegation, subject to Backend authorization. + +There are no current native ready/intake-ready/queue/six-state transition operations. Assign is delegation without a separate Queue; Worker starts are separately guarded and require the current observed `orchestrator_assignment_id` for Orchestrator calls. Assignment public operations call shared typed `WorkspaceClient::execute_server_operation` forwarding with actual Server DTOs; no direct HTTP/storage fallback or second ledger is introduced. Role assignment tools target Orchestrator responsibility only, not Worker execution/lifecycle; target principal is never the actor. Query first, preserve exact operation/input on retry, and reread after conflict/unknown outcome rather than automatically rekeying. Missing host support is a concrete authority error, never an alternate transport. No native operation is inferred for a Tool that the Feature did not register. Enabled Ticket tools claimed by the native projection are removed from `/tools`; normal Tool mode is unchanged. @@ -64,7 +74,7 @@ No native operation is inferred for a Tool that the Feature did not register. En Backend 401/403 failures map to permission denial, 404 to missing objects, and deterministic 400/409/422 validation or workflow failures to invalid arguments when the typed operation fails before any successful mutation. Cancellation and interruption remain distinct. Post-mutation canonical-reference lookups are explicitly marked as unknown-outcome stages, so even a later deterministic HTTP status cannot misreport an already-committed write as a rejection. Transport, server, malformed response, and other post-dispatch failures retain unknown outcome, and mutating operations are not retried automatically. -Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket, while queue results invalidate every reported queued Ticket, because those objects' incoming relations or workflow states changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. +Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket because its inverse relation view changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. ## Preserved Ticket contracts @@ -72,11 +82,12 @@ The projection invokes the existing Tool and scoped Backend routes, so the follo - item content digest and audited item/thread/state events; - title/body/targets input validation; -- planning-to-ready target validation and write-target requirement; -- queue dependency traversal, planning-dependency rejection and cycle detection; -- expected-state workflow transitions and close semantics; +- open/closed case judgments with item/state/closure-key CAS, typed reason and immutable close/correction/reopen history; +- stable classification tag IDs, tag operation CAS, full tag-set comparison and exact-retry receipts; +- immutable assignment identity and self-reported waiting/inprogress/done, observed event CAS and complete-payload exact replay; +- dependencies as diagnostics rather than ready/Queue or blanket launch gates; - canonical `T-*`/internal-ID resolution and Workspace isolation; - exact forward relation ownership, inverse views and relation target validation; - orchestration-plan validation and bounded query behavior. -The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode. +The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Handling is never inferred from delivery, Worker or MR state. Waiting answers continue the same assignment; explicit done reissue creates a new row and preserves the prior result. Done does not close the case, release work, stop a Worker or require unassignment. Generic reasoned closure has no universal MR/human-approval gate; explicit user conditions and guarded MR integration remain separate. Exact retry retains the same operation key and full input; a receipt is not a fresh observation. On stale/current-assignment or CAS conflicts reread and reconcile; unknown outcomes must not be silently retried as a fresh operation. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode. diff --git a/docs/design/workspace-kanban-orchestrator-runtime.md b/docs/design/workspace-kanban-orchestrator-runtime.md index 80ee7a4fc..42b30dd3e 100644 --- a/docs/design/workspace-kanban-orchestrator-runtime.md +++ b/docs/design/workspace-kanban-orchestrator-runtime.md @@ -1,5 +1,7 @@ # Workspace Kanban to backend Orchestrator runtime +> Historical design, not current operating instructions. O-19 / T-739–T-743 supersede the state/Queue routing and dependency start gates below. Current Ticket cases are open/closed; Assign starts delegation and initial waiting without ready/Queue, handling is an Orchestrator self-report, and MR integration remains independently guarded. See [current work operations](../development/work-items.md), [assignment tracking](../development/ticket-assignment-tracking.md), and [delivery/start fences](../development/ticket-assignment-delivery.md). The original proposal below is preserved, not rewritten as implementation history. + Workspace Kanban operations are control-plane requests. They may change Ticket state and request orchestration, but they must not directly execute shell, git, filesystem work, or send authority-bearing messages to raw local Worker sockets. The durable boundary is an orchestration event consumed by a backend-internal Orchestrator Worker. This document records the design boundary for connecting Kanban operations, Tickets, the Workspace backend, `WorkerRuntimeRegistry`, and filesystem-capable Workers. It is intentionally a planning artifact: it does not require the Workspace backend to implement every table, API, remote protocol, or spawn adapter immediately. diff --git a/docs/development/ticket-assignment-delivery.md b/docs/development/ticket-assignment-delivery.md index 59fa87214..e6b6a51a5 100644 --- a/docs/development/ticket-assignment-delivery.md +++ b/docs/development/ticket-assignment-delivery.md @@ -45,7 +45,11 @@ Unfinished dependencies remain query/relation diagnostics and do not disable man Action projection permits delegation independently of existing Worker occupancy; actual Worker start eligibility still excludes unfinished Worker work and closed cases. `can_start_manual_worker` no longer includes an unresolved-dependency predicate. `can_assign_orchestrator` permits an explicit fresh Assign/reissue operation even when a current row exists; callers can supply the observed expected assignment for CAS. `can_unassign_orchestrator` uses the retained current pointer, not active-work or case-state filtering. These authoring actions are not Worker transfer. Final browser controls must use these Backend decisions rather than recreating old phase/dependency gates. -Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. T-743 can consume the operational query independently of handling without inventing a third status in tracking. +Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. At the T-743 base `56795fb9`, `openapi/server-api.json` exposes assignment tracking and start fences but no assignment delivery query/receipt endpoint. The Store scan is an internal Rust API, not an authenticated browser/normal Tool/WIP route. Consumers must report this concrete API insufficiency for the owning Server/API handoff, not claim they can query delivery or infer it from handling/Worker state. Saved Assign plus delivery failure remains a saved delegation, never a reason to reassign. Any future diagnostics route must keep delivery, acceptance and actual start evidence separate from self-reported handling, and preserve bounded Workspace/assignment-scoped read authority. + +## T-743 public handoff following the base API gap + +The accepted T-743 public API adds canonical/browser `assignment-diagnostics`, `assignment-tracking/assignments` and fenced `assignment-answer` routes over the existing assignment/origin/delivery/start/receipt authority. The corresponding normal Tools/native WIP use shared typed Workspace forwarding. Diagnostics takes assignment identity and independent input/start cursors, bounds each page, and retains unknown/history distinctions; it does not expose saved rendered content or binding credentials, attest actual model processing, or mutate handling. Continuing answer atomically fences authenticated source/current assignment/observed event and saves the input/receipt, then triggers committed-input notification; it creates neither a new row nor automatic inprogress. These definitions supersede the base's unavailable public read/input surfaces, not the underlying delivery ledger or T-748 transaction ownership. See [assignment tracking](ticket-assignment-tracking.md) for ordinary Tools/WIP names and CAS/retry contracts. Combined route/generated/runtime validation is separately required; source definitions alone do not prove environment rollout. ## DB caller/transaction handoff for T-748 and protocol boundaries diff --git a/docs/development/ticket-assignment-tracking.md b/docs/development/ticket-assignment-tracking.md index fe71acde3..d9453aed4 100644 --- a/docs/development/ticket-assignment-tracking.md +++ b/docs/development/ticket-assignment-tracking.md @@ -30,7 +30,9 @@ Canonical Workspace endpoints: - `POST` on the same path: self-report update with event CAS and exact retry key. - `GET .../assignment-tracking/history`: assignment-scoped handling audit, with `assignment_id`, optional `after_event_id`, and `limit` (1–100). The returned `next_event_id` continues that history. -Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. Ticket query/detail and model projections include current assignment tracking. Read access does not grant mutation authority; Backend checks every update. +Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. T-743's public handoff adds `TicketAssignmentsQuery` for bounded retained Orchestrator rows (`GET .../assignment-tracking/assignments`, `after_assignment_id`, limit 1–100, `next_assignment_id`), `TicketAssignmentDiagnostics` for sanitized independent delivery/start pages (`GET .../assignment-diagnostics`, assignment ID, separate input/start cursors), and `TicketAssignmentAnswer` (`POST .../assignment-answer`). Answer uses the existing authenticated operation receipts with assignment/event CAS and bounded body; it continues waiting/inprogress on the same row and does not change handling. Done requires explicit reissue instead. + +`TicketOrchestratorAssign` / `TicketOrchestratorClear` use the existing role-assignment routes with a target principal, observed assignment identity and stable `operation_id`; explicit nullable `expected_assignment_id` is mandatory in Assign Tool/WIP input. These tools target responsibility, not Worker execution/lifecycle, and never derive the actor from model role text. Query first; retry exact full input with the original identity, never automatically generate a fresh key on stale/unknown outcome. Typed forwarding is shared `WorkspaceClient::execute_server_operation`; it does not introduce another ledger or direct HTTP/storage fallback. Read-only/review access includes retained rows and diagnostics, not Assign/clear/answer/self-report mutation. Ticket query/detail and model projections include current assignment tracking; `QueryTicket` also exposes exact current `assigned_principal` and self-reported `handling_status` filters. Read access does not grant mutation authority; Backend checks every update. Read a fresh projection before a new update. Do not treat a replayed receipt's last-event identity as the latest observed value. Retained-assignment query exposes Assign attribution and release/replacement independently; the history endpoint pages initial/migrated handling and subsequent self-reports, not delivery acknowledgements. diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 6e4c4d5df..d39a5fb0d 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -6,7 +6,9 @@ Workspace Server control-plane records, append-only Ticket events, and any linke ## Concepts -- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and resolution history. +- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and immutable closure history. Its case state is only `open` or `closed`; open is not readiness, and closed is not necessarily fulfillment. +- `Classification tag`: Workspace-owned stable identity with a mutable display name. Multiple tags classify/filter Tickets; they never substitute for closure reasons, delegation or start authority. +- `Orchestrator handling`: self-reported `waiting` / `inprogress` / `done` on the current assignment row, with waiting reason, progress, next action and result. These are not case states, Worker states, MR judgments or delivery status. - `Objective`: a first-class medium-term goal with background, strategy, success criteria, and canonical Ticket context links. It neither schedules nor authorizes Ticket work. - `Task`: session-local progress tracking, not the project record. - `Assignment`: an authorized delegation of Ticket work to a Worker, independent from its Profile or Flow. @@ -25,13 +27,17 @@ The Server resolves Workspace access, active configuration, and immutable Profil `SpawnTicketWorker` starts and atomically assigns a generic Worker with an `initial_request`. Its registered `profile` is optional (omitted/null selects `builtin:ticket-worker`); `flow` and alias-keyed `workdir_attachments` are optional. No repository or Flow is required for non-code work. Choose an explicit Profile when its tools and instructions fit the task; assignment does not depend on choosing the Coder role. Backend validates claims, derives repository attachment access from declared Ticket targets, and preserves explicit ExternalGrant ceilings. Callers cannot request capabilities through attachment selection. -The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Do not pre-write `inprogress` or mistake a state update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying or spawning a replacement. After acceptance, reread Ticket state and current Worker responsibility. +Orchestrator Assign delegates handling and persists initial waiting with the assignment/audit/receipt; no separate ready/Queue operation is required. The stable `workspace_agent:workspace-orchestrator` principal is distinct from the concrete registered Worker. Answers during waiting/ongoing work continue the same assignment; status is explicitly self-reported, never automatically set on delivery or acceptance. An explicit reissue after done creates a new assignment row even for the same principal, preserving prior result/release/replacement history. Reopen and Worker exchange alone create neither a fresh delegation nor execution rights. See [assignment tracking](ticket-assignment-tracking.md) and [delivery/start fences](ticket-assignment-delivery.md). + +The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Current Orchestrator starts (including generic Worker spawn and existing-Worker assignment) must carry the observed `orchestrator_assignment_id`. Do not pre-write handling `inprogress` or mistake a case/status update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying the same operation; never silently spawn a replacement. After acceptance, reread current Worker responsibility and authoritative unfinished work, not merely `current_worker`. Unfinished dependencies are diagnostics for partial work/waiting, not a blanket start refusal, and are not implicitly started; actual authentication, case closure, resource/claim and recovery guards still apply. Results can be returned in conversation, optionally recorded in a `TicketComment`, optionally published as a Drive document/artifact when that surface is available, or written/published to an authorized repository. Neither comments nor Drive artifacts are universally required. Preserve concise evidence useful to the user without fabricating work, tests, or conclusions. `implementation_report` is optional historical/audit context, not integration readiness or completion authority. The return choice is independent of result format. Examples: -- Research with review not required: answer the question and optionally record a useful summary; no MR or approval is required for an authorized completion decision. +- Research returned to the human: record handling done with a bounded result; leave the case open when no closure was requested. No MR or universal approval is required. +- Explicit user confirmation before continuing: record waiting with the specific reason and next action. An answer continues the same assignment; receipt alone is not an inprogress self-report. +- Whole request fulfilled and closure requested: separately record handling and a reasoned case close with `completed`. Other closure reasons (not planned, duplicate, unknown, or custom) do not claim fulfillment. - Code requiring independent review before merge: use the trusted review path and guarded MR integration. A Profile, Flow transition, tool invocation, or prose approval is not the review judgment. - Return without conclusion: report the result/limitations and leave the Ticket unconcluded. A finished turn, an artifact, or an approved MR does not itself decide completion. @@ -41,9 +47,11 @@ These are instruction/tool contracts, not a claim that a mechanism proves real a `QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item content and `content_digest`, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read. -Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current `content_digest` as `expected_content_digest`, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction. +Useful mutation surfaces include `TicketCreate`, `TicketComment`, classification and relation/plan tools, `TicketOrchestratorAssign` (explicit delegation or reissue), `TicketOrchestratorClear`, fenced `TicketAssignmentAnswer`, `TicketAssignmentTrackingUpdate`, and `TicketCaseUpdate`. Read current tracking first; use `TicketAssignmentsQuery` for bounded retained rows and `TicketAssignmentDiagnostics` for sanitized separate input/start evidence. `QueryTicket` supports exact current principal and self-reported handling filters. Exposure depends on the effective Profile/Feature configuration and does not grant authority. Ready, Queue, six-state transitions and `CompleteTicket`/`TicketClose` are not current Worker operations; old events remain historical evidence. -`CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint. +Assign/reissue uses target principal, explicitly observed nullable `expected_assignment_id`, and stable `operation_id`; clear uses the observed assignment_id and operation_id. These role operations do not start/stop Workers. Answers use the same current assignment/event CAS and stable operation_key with bounded body, never an unfenced comment to manufacture continuation. Case judgments use current `content_digest` as `expected_content_digest`, expected open/closed state, explicitly observed nullable `closure_operation_key`, typed close/reopen/correct-closure action, explanation, stable operation key and optional references. Tracking updates use immutable `assignment_id`, observed `last_event_id` as `expected_last_event_id`, stable `operation_key` and the complete handling payload. Tag rename/delete mutations use stable tag identity and observed `expected_last_operation_key`; Ticket tag-set updates compare the observed full `expected_tag_ids` set and send the complete desired `tag_ids` set, never a display name or status-only comparison. Exact retry keeps the same key and entire input; changed input under that key is rejected. Replayed receipts are not fresh observations. After conflict, reread and reconcile, never silently overwrite or retry as a new operation against another assignment. Unknown dispatch outcomes are not success or failure. Saved Assign plus failed notification is not failed delegation and must not prompt another Assign. Do not infer missing delivery/acceptance diagnostics from Worker or handling state; report the concrete unavailable endpoint. + +`TicketCaseUpdate` records an authenticated close, reopen or correction judgment with immutable reason/actor/history. It neither integrates an MR nor expands grants nor changes handling status. Handling done and case closed do not imply one another; done does not require unassignment. Reopen alone is not reissue or old Worker revival. Generic closure can occur without commits, MRs or approvals when consistent with the request, while explicit user approval conditions must be respected. Backend checks actor authority, CAS, replay and resource boundaries, not natural-language satisfaction. Code's guarded MR integration remains separate: do not claim an unmerged result was integrated. Relations remain metadata, not a scheduling mechanism: record actual project dependencies, but do not encode capacity, Worker/worktree ownership, or execution ordering as relations. Use orchestration/session records for those concerns and inspect current Backend claim rules rather than inferring launch eligibility from a relation alone. @@ -80,7 +88,7 @@ Intake clarifies ambiguous intent, checks duplicates and relevant context, and r A useful Ticket records background/motivation, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, and relevant open questions/risks. Include useful result evidence and final resolution when appropriate. Separate confirmed facts, user claims, hypotheses, and undecided questions. Do not copy Worker role boundaries, a temporary plan, or a decision to stop after authoring into the Ticket as a product prohibition. Record an approval gate only when the user asks for it; do not prematurely prescribe implementation tactics. -Risk flags prompt context checks and reviewer focus, not automatic stop gates. Return to planning only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints. +Risk flags prompt context checks and reviewer focus, not automatic stop gates. Record a waiting reason and next action only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints or moving the case to a planning state. Do not create new umbrella/progress-container Tickets. Split broad work into concrete useful outcomes, record the split in Ticket/Objective context, and use Objectives for enduring medium-term goals. Do not replace umbrellas with parent/child, sub-ticket, part-of, contains, or other hierarchy relations. A concrete planning/design/investigation Ticket is valid when requested. Existing umbrellas can be retired as superseded/decomposed without rewriting history; distinguish container retirement from completion of future work. diff --git a/docs/report/T-743-T748-callers.md b/docs/report/T-743-T748-callers.md new file mode 100644 index 000000000..510ee2fd0 --- /dev/null +++ b/docs/report/T-743-T748-callers.md @@ -0,0 +1,78 @@ +# T-743 → T-748: published domain-helper / caller handoff + +## Source identity and composition + +This inventory belongs to the T-743 commit carrying this file on `work/T-743-ticket-public-surfaces` (T-742 base `56795fb92cb9def9bcd701bd51d92e99526f8eff`). Its exact published SHA and MR ID are recorded in the Ticket publication handoff. It is not a copy of the unpublished T-748 adapter and is not evidence that async DB execution has been integrated or reviewed. + +Normal-merge the validated public T-748 source into this existing work branch. Keep the MR's source/target selectors. Recheck the concrete new call graph below against the merged implementation; no placeholder executor, second connection authority, transaction split, fresh integration branch or force push is needed. + +## Three added synchronous Store signatures + +Declared in `crates/workspace-server/src/store.rs` on `ControlPlaneStore`, implemented by `SqliteWorkspaceStore`: + +```rust +fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result; + +fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, +) -> Result; + +fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, +) -> Result; +``` + +Implementations forward whole calls respectively to `assignment_tracking::assignments_page`, `assignment_delivery::diagnostics_page` and `assignment_tracking::answer`. + +The answer helper owns one IMMEDIATE transaction: `require_assignment_source` (including replay), current actionable delegation admission for a new operation, `ticket::sqlite_answer_ticket_assignment_in_transaction` (full input/source fingerprint, retained receipt replay, observed assignment/event, open-case gate, body event, audit attributes and immutable operation result), then commit. Keep this complete helper inside one accepted DB job. Do not validate/CAS on one side of an await and append/receipt on the other. The two read helpers use their existing connection/snapshot boundaries and bounded cursors; the diagnostics input and start cursors are independent. + +## Added async HTTP callers and surrounding synchronous work + +In `crates/workspace-server/src/server.rs`, `WorkspaceContractService`: + +| Canonical caller | Browser forwarding caller | Whole Store operation | +| --- | --- | --- | +| `ticket_assignments_page` | `ticket_assignments_page_browser` | `page_ticket_assignments` | +| `ticket_assignment_diagnostics` | `ticket_assignment_diagnostics_browser` | `page_ticket_assignment_diagnostics` | +| `ticket_assignment_answer` | `ticket_assignment_answer_browser` | `answer_ticket_assignment` | + +Canonical paths are `/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments`, `/assignment-diagnostics` and `/assignment-answer`; Browser equivalents use `/api/w/{workspace_id}/tickets/{ticket_id}/...`. Browser methods forward to canonical methods, so they must not independently repeat a mutation or transaction. + +All three canonical methods currently call `resolve_workspace_ticket_reference` before their Store method. This uses Ticket authority/backend DB reads, so moving only the final Store method is not enough. The answer path also calls `authenticated_assignment_source`; its Runtime branch checks the registered current Workspace Orchestrator through `find_workspace_orchestrator`, while its Account branch derives actor/source exclusively from authenticated request context. Preserve source authentication and the in-transaction source fence; do not turn request target principal into source authority. Own/cloned workspace/Ticket/query/body/context/actor values must cross the common execution boundary without borrowing request frames. + +After the answer returns committed, the current handler calls `find_workspace_orchestrator` and `dispatch_orchestrator_assignment_attention`. Account for the former's DB-backed projection lookup and the latter's Runtime/network dispatch in T-748's existing stage/lane design. Runtime/network waits must not occupy the sole DB worker. Keep commit-before-publication and durable scan/recovery semantics even if the awaiting HTTP request is cancelled or its response is lost. Do not automatically resubmit an accepted mutation with a new operation key. + +## Query authority extension and all entry paths + +`TicketQueryRequest` adds typed `assigned_principal`, `handling_status` and `closure_reason`. Existing `TicketAuthority::query_tickets(query: TicketQueryRequest) -> Result` in `authority.rs` applies current role/principal/handling and current closed-case reason constraints in its SQL candidate query **before** limit/pagination. Query text separately searches retained closure judgments, including after correction/reopen. + +The HTTP call graph remains `WorkspaceContractService::ticket_query` → `scoped_query_tickets` → `api.authority.query_tickets(query)`. Canonical and Browser query routing, including the pre-existing browser GET query adapter, converge on this authority. T-748 must cover the whole authority read/enrichment call, not just the added SQL fragment or only one UI route. + +Normal Tool/WIP read/assign/release/answer calls forward typed `WorkspaceServerOperation` values from `crates/worker/src/worker.rs`, through `crates/worker-runtime/src/worker_source.rs`, to the same canonical HTTP contracts. Added variants are `TicketAssignmentSet`, `TicketAssignmentClear`, `TicketAssignmentsPage`, `TicketAssignmentDiagnostics`, and `TicketAssignmentAnswer`; `QueryTicket` uses the existing typed query transport with the added filters. The set/clear variants expose existing assignment Store authority rather than creating additional new Store helpers. Their existing T-748 execution-boundary changes must be retained when resolving merge conflicts. + +No new Tool-specific DB adapter is needed. Frontend helpers in `ticket-operations.ts`, TicketHandling/Tags and list/Orchestrator loaders are consumers, never replacement authorities. + +## Composition validation + +Existing T-743 proof: full selected Rust crate suites, generated contract checks, Web/component/browser tests and real-Worker notification/processing synchronization. See `T-743-public-surface-validation.md` for commands and explicit limits. + +After T-748 composition, add/run the narrow boundary checks for all three new HTTP operations and query paths under controlled DB lock/admission, queue/job bounds, overload response, stale/source refusal, rollback, accepted-job cancellation and exact replay. Retain cookie Origin and signed Runtime-binding denial tests, assignment source fencing, one-transaction receipt/event tests and canonical/Browser route equivalence. Root check and relevant full changed-crate tests must cover the combined tree. Then publish only the same source branch normally, verify provider SHA equals local HEAD, and request fresh independent review of that exact combined source. + +Current publication is neither final independent approval nor target integration, Ticket/MR completion, live update or cleanup. diff --git a/docs/report/T-743-assignment-tools-handoff.md b/docs/report/T-743-assignment-tools-handoff.md new file mode 100644 index 000000000..95cf75162 --- /dev/null +++ b/docs/report/T-743-assignment-tools-handoff.md @@ -0,0 +1,51 @@ +# T-743 additional assignment Tools / WIP handoff + +This is an additive follow-up to `T-743-prompt-surfaces.md`; that prior report/history is not rewritten. Additional scope follows T-743 thread sequence 6 and the parent request. Read current typed T-743 and inspected the backend child's actual Server DTOs/routes, not just the proposed names. + +## Implemented public surface + +| Normal Tool | Native Ticket item operation | Contract | +| --- | --- | --- | +| `TicketOrchestratorAssign` | `orchestrator_assign` | Explicit delegation/reissue to target principal; required observed nullable `expected_assignment_id`, stable `operation_id` | +| `TicketOrchestratorClear` | `orchestrator_clear` | Explicit release of observed assignment_id with stable operation_id; not Worker lifecycle | +| `TicketAssignmentsQuery` | `assignments_query` | Bounded retained Orchestrator rows, after_assignment_id / next_assignment_id | +| `TicketAssignmentDiagnostics` | `assignment_diagnostics` | Actual sanitized DTO with separate input/start cursors and unknown/history distinctions | +| `TicketAssignmentAnswer` | `assignment_answer` | Same current waiting/inprogress assignment, observed event CAS, stable operation_key, bounded body | + +Reissue is the explicit Assign operation with a new caller-chosen operation identity against the observed old assignment. Exact replay retains the original key, full principal/CAS/input; no tool automatically queries a newer row, rewrites the expected identity or generates a new operation. All descriptions require querying current authority first. Target principal is operation data, never source/actor authority; the tools fix the target role to Orchestrator responsibility, leaving real role/principal/authentication checks in Backend. No worker-start/transfer operation was invented. + +The new endpoints are the actual typed canonical definitions for `assignment-tracking/assignments`, `assignment-diagnostics` and `assignment-answer`. `WorkspaceClient` has no dedicated role-assignment method: the shared typed `WorkspaceServerOperation` forwarding surface was extended in the Worker crate, using actual `server_api` request DTOs and the existing host-bound execution authority. No client-crate extension is needed for these operations, no direct HTTP/storage fallback is used by the tools, and no second assignment/delivery/receipt ledger was introduced. + +`QueryTicket` now accepts the actual typed current `assigned_principal`, `handling_status`, and current closed-case `closure_reason`. Closure reason remains separate from classification tag IDs. New read-only/review surfaces contain retained query and diagnostics, never Assign/clear/answer/self-report mutation. Mutation exposure is additive from the actual manifest authoring/thread/workflow flags; availability is not Backend authorization. + +## Changed paths in this follow-up + +- `crates/worker/src/worker.rs`: shared typed operation forwarding and scalar encoded query transport. +- `crates/worker/src/feature/builtin/ticket.rs`: feature inventories, query schema, native operation mappings and registration. +- `crates/worker/src/feature/builtin/ticket/assignment_public.rs`: new Tools, schemas, input validation and typed bounded/sanitized response handling. +- `crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs`: new boundary/transport/WIP/access/profile fixtures. +- `crates/worker/src/prompt/system.rs`: effective actual-profile research return, explicit confirmation wait, same-row answer and generic close fixtures without repository/MR prerequisites; guarded integration fixture remains separate. +- `resources/prompts/common/tickets.md`: concrete tool names, query-first/CAS/exact replay/actor boundaries. +- `docs/development/ticket-assignment-tracking.md` +- `docs/development/ticket-assignment-delivery.md` +- `docs/development/work-items.md` +- `docs/design/ticket-wip-projection.md` +- this new report. + +## Validation evidence + +Passed focused commands: + +- `cargo test -p worker --lib feature::builtin::ticket::`: **57 passed**, including 12 new public assignment/filter tests. These exercise required observed null/CAS, unknown model authority fields, same-input replay forwarding, read/diagnostic cursor encoding and bounds, answer body/identity validation, stale/permission/unknown outcome non-retry, diagnostic content/credential stripping, ordinary/WIP equivalence and route binding, read-only exclusions, and actual resolved builtin manifest access. +- `cargo test -p worker --lib prompt::system::tests::effective_`: **5 passed**, including the actual resolved-profile no-repo/no-MR research→waiting+answer→reasoned-close fixture and the independent guarded MR-review fixture. +- `cargo test -p worker --lib feature::builtin::orchestration::`: **7 passed**. +- `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`: **1 passed**. The prior concurrent macro blocker no longer prevented this test. +- `cargo test -p worker --test feature_invocation_test`: **13 passed**. +- Root `cargo check`: passed at the observed combined snapshot, including Runtime, TUI and Server compile closure. +- Direct delegated-file `rustfmt --check`, `git diff --check HEAD`, and naming check: passed (`558 matching lines; 0 unapproved; 0 stale exceptions`). + +Root `cargo fmt --all -- --check` was **not clean** at the latest observation: non-delegated concurrent edits still needed formatting in `crates/worker-runtime/src/worker_source.rs` and `crates/workspace-server/src/authority.rs`. No outside-scope formatting write was performed. Parent owns the final combined formatting/validation pass. + +## Limits and return + +Transport/schema fixtures use a recording host-bound Workspace adapter; they prove concrete tool wiring, boundary input, permissions and non-retry behavior, not fresh signed Runtime→Server→DB→notification end-to-end execution or model judgment. Backend child's actual persistence/source-fence tests, generated contract synchronization, UI/browser/visual validation, all-Worker/full-stack tests, final combined acceptance and environment rollout remain parent-owned/separate. New API source definitions and one successful root compile are not whole-Ticket satisfaction evidence. No T-743 completion, review request, commits, pushes, merge, Worker stop or backend ledger implementation was performed. diff --git a/docs/report/T-743-prompt-surfaces.md b/docs/report/T-743-prompt-surfaces.md new file mode 100644 index 000000000..6fc1e1c62 --- /dev/null +++ b/docs/report/T-743-prompt-surfaces.md @@ -0,0 +1,64 @@ +# T-743 delegated Prompt / Profile / Flow / Worker handoff + +## Boundaries + +Base: `56795fb92cb9def9bcd701bd51d92e99526f8eff`, branch `work/T-743-ticket-public-surfaces`. Read typed T-743, O-19 and T-739–T-742, assignment tracking/delivery docs, generated OpenAPI and the Rust testing strategy before tests. This report covers only delegated resources/docs/Worker changes, not the parent's UI/Server/API work. No backend ledger, commits, pushes, merge, review request or Ticket conclusion was performed. Existing reports and history were not rewritten. Two historical design documents received only a supersession notice above their preserved original content. + +## Changes + +- Remove ready/intake-ready/Queue from normal Worker Ticket catalogs and native WIP mappings, including all-capability configurations. Remove obsolete queue-response observation invalidation; preserve inverse relation invalidation. Internal trait adapters are not new public operations. +- Align common/role/panel instructions: cases open/closed; stable classification IDs and actual tag/set CAS; assignment waiting/inprogress/done is self-report, independent of Worker/MR/delivery/acceptance/work release. +- Answers during waiting/ongoing handling continue the same assignment; explicit done reissue creates a new row even for the same principal and preserves old results. Reopen and Worker exchange alone are not reissue. Done/close do not require unassignment. +- Explain full-input operation identity/replay, observed assignment/event CAS, stale reread/reconciliation and unknown outcomes without automatic fresh-operation retry. Distinguish saved Assign from failed delivery. Supply observed `orchestrator_assignment_id` for Orchestrator starts, including generic spawn/existing-Worker assignment. +- Preserve explicit user approval conditions and guarded trusted review for MR integration, without a universal MR/human approval gate on generic closure. Flow terminal handoff is not case closure, handling done, work release or Worker stop. +- Add catalog exclusion and effective prompt/schema fixtures for research return, confirmation wait and reasoned closure. These are scripted instruction/schema checks, not model judgment or full-stack behavior tests. + +## Delegated diff paths + +Worker: +- `crates/worker/src/feature/builtin/ticket.rs` +- `crates/worker/src/feature/builtin/ticket/assignment_tracking.rs` +- `crates/worker/src/prompt/system.rs` + +Resources: +- `resources/flows/coder-review.dcdl` +- `resources/profiles/intake.dcdl` +- `resources/profiles/orchestrator.dcdl` +- `resources/prompts/common/tickets.md` +- `resources/prompts/panel/orchestrator_idle_queue_notice.md` +- `resources/prompts/role/coder.md` +- `resources/prompts/role/intake.md` +- `resources/prompts/role/orchestrator.md` +- `resources/prompts/role/ticket-worker.md` + +Current docs / historical-status notices: +- `docs/development/work-items.md` +- `docs/development/ticket-assignment-delivery.md` +- `docs/design/ticket-wip-projection.md` +- `docs/design/merge-request-wip-projection.md` +- `docs/design/ticket-evidence.md` +- `docs/design/flow-state-graph.md` +- `docs/design/workspace-kanban-orchestrator-runtime.md` (notice only) +- `docs/design/orchestrator-worktree-layout.md` (notice only) +- this new report + +## Focused validation evidence + +Passed: +- `cargo test -p worker --lib prompt::system::tests::effective_`: 5 tests (including new return/wait/close schema fixture). +- `cargo test -p worker --lib feature::builtin::ticket::`: 45 tests, including normal/WIP retirement, case CAS, tag schemas/transport, assignment tracking and relation observation tests. +- `cargo test -p worker --lib feature::builtin::orchestration::`: 7 tests, including observed delegation forwarding and resource/Profile boundaries. +- `cargo test -p manifest --lib builtin_profile::tests::`: 4 tests. +- `cargo test -p manifest --lib profile::tests::builtin_orchestrator_keeps_cleanup_tool_providers_enabled`: 1 test. +- `cargo test -p flow --lib builtin::tests::`: 2 tests; built-in recipe compiles and keeps current selector review contracts. +- Root `cargo check`, `cargo fmt --all -- --check`, `git diff --check HEAD`, and naming check (`558 matching lines; 0 unapproved; 0 stale exceptions`) passed at an earlier snapshot before subsequent concurrent Server/API edits. Worker files were formatted again after the final fixture addition; diff check also passed afterward. + +One subsequent Worker spawn-contract invocation was blocked before test execution by a concurrent parent-owned API edit: `crates/server-api/src/lib.rs:3208` (`ticket_assignments_page`) used `#[context] context: ServerRequestContext`; the API macro requires `#[path]`, `#[query]`, `#[header]`, `#[body]`, `#[binary]` or `#[extension]` and rejected it. Command: `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`. No test result or final combined-tree compile success is claimed for that invocation. Parent must revalidate its current API/combined tree. + +## Concrete API / runtime limits + +At base `56795fb9`, generated `openapi/server-api.json` has assignment tracking/history and start fences but no assignment-delivery query/receipt HTTP endpoint. `list_pending_ticket_assignment_deliveries` is an internal Store method, not a browser or normal Tool/WIP route. Normal Worker/WIP Ticket inventories also have no Orchestrator Assign/reissue/clear operation; this change removes obsolete Queue rather than fabricating a replacement operation or ledger. The parent owns any public endpoint/Tool handoff needed to satisfy the whole Ticket. Instructions explicitly require reporting unavailable typed operations and forbid inferring delivery/acceptance from status or Worker state. + +The granted live typed Ticket tools still returned legacy six-state projections for the requested reads; those reads established requirements/history, not evidence that the running environment has adopted the repository's new API. No environment update occurred. + +Not run/claimed: whole Worker test suite, Web/browser/visual validation, generated-contract regeneration/check, live migration, model judgment evaluation, full-stack E2E, final combined-tree root compile after concurrent API changes, or T-748 DB-boundary validation. Parent's concurrent Ticket/Server/API/Web changes are excluded from this implementation's evidence. diff --git a/docs/report/T-743-public-surface-validation.md b/docs/report/T-743-public-surface-validation.md new file mode 100644 index 000000000..43e04364e --- /dev/null +++ b/docs/report/T-743-public-surface-validation.md @@ -0,0 +1,58 @@ +# T-743 public surfaces: combined implementation and validation + +## Snapshot and scope + +Work branch: `work/T-743-ticket-public-surfaces`, based on T-742's `56795fb92cb9def9bcd701bd51d92e99526f8eff`. This is implementation evidence, not an integration, approval, Ticket conclusion or live-environment update. See the delegated Prompt and Tool reports in this directory for their narrower evidence and earlier blockers; the checks below supersede their earlier compile/format limitations for the combined snapshot. + +The existing Ticket, assignment tracking, assignment delivery and start records remain the authorities. No browser ledger, alternative assignment store or copied DB executor was added. + +## Implemented boundaries + +- Ticket list and detail use open/closed case state, stable classification tag IDs and an independent current responsibility/handling projection. Tags and current/historical closure reasons remain distinct. List filtering for tag IDs, principal, case and current close reason is server-side and preserves cursors; ordinary text search can match retained closure reasons after correction/reopen. +- The Orchestrator page reads current assignments with waiting/inprogress/done, reasons, progress, next action and results. Worker liveness, MR evidence, delivery and acceptance do not manufacture a handling status. +- Detail supports classification catalog create/list/rename/delete, multiple membership changes, reasoned close/correction/reopen, Assign, observed replacement/reissue, release, retained assignment/result/release history and explicit assignment-fenced answers. Responsibility principal choice uses the real typed user/Worker/Workspace-agent request, not caller authentication data. Backend still validates identities and actual authority. +- A saved Assign is not reported as successful delivery or execution. Unknown/stale/permission/unavailable outcomes are distinct. Exact operation snapshots preserve the original key, observed assignment/event or tag set and body; no automatic new delegation is substituted after a lost response. +- Pending child operations and observations reset on the Workspace **and** Ticket key, including Workspace changes that retain the same Ticket ID. +- Normal Tools/WIP and effective Prompt/Profile/Flow descriptions use Assign as delegation, no ready/Queue prerequisite, same-row continuation answers, explicit new-row reissue and separate case judgment. Retained responsibility and Done are not Worker activity, merge or third-party approval. Historical reports were not rewritten; superseded design descriptions have explicit notices. + +### Canonical API contacts + +Canonical `/workspaces/{workspace_id}/tickets/{ticket_id}` and `/api/w/{workspace_id}/tickets/{ticket_id}` routes expose: + +- GET `assignment-tracking/assignments`: bounded retained rows with assignment-identity pagination. +- GET `assignment-diagnostics`: sanitized existing input/delivery/ack/start records, independent input/start cursors, unknown origin/acceptance/actual-processing distinctions; no rendered notification content or binding credentials. +- POST `assignment-answer`: authenticated source, observed assignment and handling-event CAS, stable operation-key receipt, body event and audit under one IMMEDIATE transaction. Publication occurs only after the helper returns with its transaction committed. Exact replay retains the receipt; generic history comments do not become unfenced continuing input. +- Shared Ticket query principal/handling/current-closure-reason filtering is applied in SQL before paging. + +## Validation + +Passed on the combined working snapshot: + +- Root `cargo check` (normal TUI/Server compile closure). +- `cargo test --no-fail-fast -p ticket -p worker-runtime -p worker -p yoi-workspace-server -p server-api`: all selected crate targets passed. Library counts: server-api 98; ticket 99; worker 994; worker-runtime 417; workspace-server 946 with one existing ignored test. Included integration/binary targets also passed. Log: `target/t743-changed-crates-final.log`. +- Focused real-Worker delivery fixture now synchronizes on a canonical `RunCompleted` **after** the latest notification plus final Idle, not the initial Idle window, a fixed sleep or acceptance alone. The original pre-processing Idle failure was reproduced and the focused test plus full Server suite passed after the fix. +- Focused manifest builtin-profile (4) and builtin Flow (2) tests passed. Effective research-return, confirmation-wait/same-assignment-answer and separate reasoned-close fixtures are included in the Worker suite. +- Web `deno task test`: **613 passed**. Web `deno task test:component`: **318 passed**. Web check: **0 errors, 0 warnings**. Static production build passed (existing chunk-size advisory remains). +- `ticket_shell_test.ts` plus `ticket_principal_test.ts`: **7 Chromium browser tests passed**. These exercise repository-free creation, server-filter pagination, known/custom close reasons, same-row answer, new-row reissue, retained results/releases, separate close/reopen, stable tag management, ambiguous saved Assign/exact retry, stale and permission denial, and the case/handling/Worker/MR/empty/error matrix in both themes. The native principal-selector test captures the selected target/observed row and waits on the second actual failed request for retry synchronization. Logs: `target/t743-browser-final.log`. +- All six server-api TypeScript generators' `--check` commands passed (ticket, repository, runtime, workdir, Worker launch and companion); generated contract provenance is synchronized with OpenAPI. +- `cargo fmt --all -- --check`, `git diff --check HEAD` and `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` passed; naming check: **558 matching lines, 0 unapproved, 0 stale exceptions**. + +Earlier validation found and corrected the obsolete TUI queued-notice expectation, two delivery tests expecting ordinary comments to generate new continuing input, and a Home fixture missing the canonical assignment-tracking array. One run also exposed an unchanged Runtime cleanup test's differing walker counts. Its focused invocation and subsequent complete selected-crate run passed; no cleanup implementation fix or claim that this intermittent issue has been eliminated is made. + +## Visual evidence + +Baseline captures: `target/web-ux/t743-baseline-light` and `t743-baseline-dark`. Detail after captures: `t743-after-light` and `t743-after-dark`; actual contact-sheet slices were viewed, not merely generated. Conditions include member/non-owner fixture, Backend action-ineligible controls, 1440/768/390px, light/dark, open+done, open+waiting/stopped Worker, inprogress/running Worker/MR, closed/history, empty, long content and deliberate errors. + +The deliberate query-error route retains its expected HTTP 503/document errors, so the full scenario's exit 2 is not presented as a clean no-error capture. Fixture "anonymous" transport is a synthetic member scenario, not proof of live anonymous authorization. + +Principal-selector captures: `t743-principals-light`/`dark` before a styling correction and `t743-principals-final-light`/`dark` after it. Each final bundle has nine captures (agent/account/Worker choices at all three widths), with no capture errors. The first inspection found cramped native fieldset controls. The fix reuses shared Ticket form styling and removes the redundant fieldset border; final light/dark contact sheets were viewed. Controls, labels and focus remain readable without page overflow. + +List/Orchestrator final evidence: `t743-list-final-reviewed-light` and `t743-list-final-reviewed-dark`, 33 captures each. Parent recovered the child's committed check/build and eight summary-model tests, inspected its final capture metadata (only the intentional query-error route had errors), and viewed actual list/Orchestrator screenshots at desktop/narrow/mobile. The refinements remove repeated offline/count prose, preserve the first mobile case in view and fold close-reason controls separately from classification. Parent verified current diff and browser tests, released the delegated file authority and owns the published handoff. This is observed tool/image evidence, not an invented child review verdict. + +## Explicit remaining work and limits + +T-748's DB execution facade is **not** copied or integrated. Its latest notified candidate is local-only and its full Server suite has unresolved failures. When T-748 publishes a validated source, normal-merge it, place each new whole synchronous domain helper/authority query into the common bounded DB execution boundary, keep the answer transaction intact, and leave Runtime/network waiting off the DB worker. Recheck cancellation/overload and commit-before-publication at the combined boundary. The exact combined published source then needs independent trusted review. + +Current evidence does not attest actual model judgment quality. Prompt/Tool fixtures are scripted inclusion/schema/transport tests; Chromium uses a loopback API fixture. The real-Worker Server fixture uses a scripted model with actual Controller/session/Runtime acceptance and canonical processing synchronization. None is a live migration/rollout, proof of actual processing for every production delivery, or completion of T-744's full real-path validation. + +No target integration, Ticket conclusion, live process update or branch/Workdir cleanup was performed. diff --git a/openapi/server-api.json b/openapi/server-api.json index a8b846fa9..4c00e0bf1 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -10607,6 +10607,171 @@ ], "type": "object" }, + "TicketAssignmentAnswer": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": "string" + }, + "body": { + "type": "string" + }, + "expected_last_event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "body" + ], + "type": "object" + }, + "TicketAssignmentAnswerReceipt": { + "properties": { + "assignment_id": { + "type": "string" + }, + "event_index": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "input_event_id": { + "type": "string" + } + }, + "required": [ + "assignment_id", + "input_event_id", + "event_index" + ], + "type": "object" + }, + "TicketAssignmentDiagnosticsPage": { + "properties": { + "actionable": { + "description": "Noncurrent/done/closed/released records are history, not pending work.", + "type": "boolean" + }, + "assignment_id": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "input_origin_known": { + "type": "boolean" + }, + "inputs": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentInputDiagnostic" + }, + "type": "array" + }, + "next_input_event_id": { + "type": [ + "string", + "null" + ] + }, + "next_start_operation_id": { + "type": [ + "string", + "null" + ] + }, + "starts": { + "description": "Assignment-scoped admission evidence; not attributed to individual input.", + "items": { + "$ref": "#/components/schemas/TicketAssignmentStartDiagnostic" + }, + "type": "array" + } + }, + "required": [ + "assignment_id", + "current", + "input_origin_known", + "actionable", + "inputs", + "starts" + ], + "type": "object" + }, + "TicketAssignmentDiagnosticsQuery": { + "additionalProperties": false, + "properties": { + "after_input_event_id": { + "type": [ + "string", + "null" + ] + }, + "after_start_operation_id": { + "type": [ + "string", + "null" + ] + }, + "assignment_id": { + "type": "string" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "assignment_id" + ], + "type": "object" + }, + "TicketAssignmentInputDeliveryState": { + "enum": [ + "not_reserved", + "reserved", + "request_persisted", + "runtime_accepted" + ], + "type": "string" + }, + "TicketAssignmentInputDiagnostic": { + "properties": { + "acceptance_outcome_unknown": { + "description": "Absence of a Runtime ack is not proof of failed external delivery.", + "type": "boolean" + }, + "delivery_id": { + "type": [ + "string", + "null" + ] + }, + "input_event_id": { + "type": "string" + }, + "notification": { + "$ref": "#/components/schemas/TicketAssignmentInputDeliveryState" + } + }, + "required": [ + "input_event_id", + "notification", + "acceptance_outcome_unknown" + ], + "type": "object" + }, "TicketAssignmentPrincipal": { "oneOf": [ { @@ -10791,6 +10956,45 @@ ], "type": "string" }, + "TicketAssignmentStartDiagnostic": { + "properties": { + "actual_processing_unknown": { + "description": "Existing records do not attest that a model processed a particular input.", + "type": "boolean" + }, + "assignment_id": { + "type": [ + "string", + "null" + ] + }, + "claim_state": { + "description": "Durable start/assignment reservation result, not Worker liveness.", + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "operation_id", + "claim_state", + "actual_processing_unknown" + ], + "type": "object" + }, "TicketAssignmentSummary": { "additionalProperties": false, "properties": { @@ -11050,6 +11254,47 @@ ], "type": "object" }, + "TicketAssignmentsPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, + "next_assignment_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "TicketAssignmentsQuery": { + "additionalProperties": false, + "properties": { + "after_assignment_id": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "TicketAttention": { "enum": [ "unresolved_review", @@ -12821,6 +13066,17 @@ "TicketQueryRequest": { "additionalProperties": false, "properties": { + "assigned_principal": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentPrincipalSnapshot" + }, + { + "type": "null" + } + ], + "description": "Exact current assigned principal; retained relationships do not match." + }, "attention": { "default": [], "items": { @@ -12828,6 +13084,17 @@ }, "type": "array" }, + "closure_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureReason" + }, + { + "type": "null" + } + ], + "description": "Exact reason of the current closed-case judgment; reopened/history-only reasons do not match." + }, "cursor": { "type": [ "string", @@ -12848,6 +13115,17 @@ }, "type": "array" }, + "handling_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTrackingStatus" + }, + { + "type": "null" + } + ], + "description": "Explicit self-reported handling only; never inferred from Worker state." + }, "limit": { "format": "uint", "maximum": 1000, @@ -16834,7 +17112,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b" + "x-yoi-source-digest": "sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9" }, "openapi": "3.1.0", "paths": { @@ -36842,6 +37120,347 @@ ] } }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer": { + "post": { + "operationId": "ticket_assignment_answer_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswerReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics": { + "get": { + "operationId": "ticket_assignment_diagnostics_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments": { + "get": { + "operationId": "ticket_assignments_page_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/workers": { "get": { "operationId": "workspace_worker_list", @@ -38897,6 +39516,240 @@ } } }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer": { + "post": { + "operationId": "ticket_assignment_answer", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswerReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics": { + "get": { + "operationId": "ticket_assignment_diagnostics", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking": { "get": { "operationId": "ticket_assignment_tracking_get", @@ -39129,6 +39982,113 @@ ] } }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments": { + "get": { + "operationId": "ticket_assignments_page", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history": { "get": { "operationId": "ticket_assignment_tracking_history", diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index 407f912a0..f09b93133 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -53,7 +53,7 @@ }; done = { - instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff."; + instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff; it is neither Ticket closed nor an assignment handling done self-report, work release, unassignment, or Worker stop. Generic Ticket closure has no universal MR gate; explicit user approval conditions and guarded MR integration remain separate."; terminal = true; }; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index e38356424..dfe0a8a23 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -12,6 +12,6 @@ import "./base.dcdl" // { web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; - ticket = { enabled = true; authoring = true; thread = true; intake = true; }; + ticket = { enabled = true; authoring = true; thread = true; }; }; } diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index 37870dcff..395451183 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -1,6 +1,6 @@ import "./base.dcdl" // { slug = "orchestrator"; - description = "Intent-led Ticket orchestration with separate guarded MR integration authority."; + description = "Intent-led Ticket delegation and self-reported assignment handling; case closure and guarded MR integration are separate."; scope = "workspace_write"; engine = { instruction = "role.orchestrator"; }; diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index 37b703801..99854b6ee 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -10,4 +10,14 @@ Choose a result and return path suited to the request: conversation, an optional 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. +Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket. + +Read the current Orchestrator delegation with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular delegation to the stable Workspace Orchestrator principal, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the Orchestrator's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` when authorized, not just conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop. + +Use `TicketOrchestratorAssign` for explicit initial delegation or reissue, with target principal, observed `expected_assignment_id` (explicit null only when unassigned), and stable `operation_id`. Query first; target principal is not the actor or an authority claim. The usual target is `{kind: "workspace_agent", agent_key: "workspace-orchestrator"}`; Backend validates role/principal and actual authentication. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation. + +Answers during waiting or ongoing handling continue the same assignment row; use `TicketAssignmentAnswer` with that `assignment_id`, observed `expected_last_event_id`, stable `operation_key`, and answer `body`, not an unfenced comment as a substitute. Do not reassign merely to deliver an answer or automatically declare inprogress on arrival. After done, an explicit reissue creates a new row/assignment_id even for the same principal and preserves the prior result. Reopen alone is neither reissue nor resurrection of an old Worker. Release/replacement remains separate from the old row's last status/result; done does not require unassignment. Worker replacement alone does not create a new delegation. Dependencies are diagnostics for judging partial work or waiting, not a blanket start gate; never start dependencies implicitly. + +For a new self-report, read the exact current assignment and send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger. + +Treat case state, assignment handling, 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. diff --git a/resources/prompts/panel/orchestrator_idle_queue_notice.md b/resources/prompts/panel/orchestrator_idle_queue_notice.md index eee42a88e..6ebc9c0a1 100644 --- a/resources/prompts/panel/orchestrator_idle_queue_notice.md +++ b/resources/prompts/panel/orchestrator_idle_queue_notice.md @@ -1,8 +1,8 @@ -Queued Tickets require attention: +Assigned Tickets have input requiring attention: {% for ticket in tickets -%} - {{ ticket.resource_key }} {{ separator }} {{ ticket.title }} {% endfor -%} {% if omitted_ticket_count > 0 -%} -Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. +Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. {% endif -%} -Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work. +Reread the current Ticket, current assignment/event identity, handling and new input before acting. Assign is delegation; no ready/Queue step is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. Supply the observed orchestrator_assignment_id for starts. Handling is a self-report, not delivery, acceptance or Worker state. Preserve authenticated Worker, unfinished work and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not create duplicate work or infer delivery success from this notice. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index b6f634ffa..39dd4cc6e 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,6 +1,6 @@ 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. +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. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Return bounded progress/results to the current delegation without claiming Orchestrator reporting authority. No ready/Queue step or mandatory terminal unassignment is part of code handoff. Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. diff --git a/resources/prompts/role/intake.md b/resources/prompts/role/intake.md index d010cfc70..9ea776510 100644 --- a/resources/prompts/role/intake.md +++ b/resources/prompts/role/intake.md @@ -8,6 +8,6 @@ Use one Ticket for a deliverable that can be explained as a specification or fea Create or update Tickets only after user agreement or an explicit user instruction to record the agreed draft. Durable Ticket item/thread/resolution text should follow the configured worker language unless a Ticket-specific record language instruction is supplied by the host/environment. -Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to the user/Orchestrator queue flow. +Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user/Orchestrator delegation. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts Orchestrator tracking without a separate ready/Queue step; this role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure. Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 31f88c771..02b05d940 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,9 @@ 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. 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. +Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close. + +Explicitly start bounded Ticket work with `SpawnTicketWorker`, the observed current `orchestrator_assignment_id`, 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. Supply that same observed delegation identity for generic `WorkerSpawn` Ticket starts and existing-Worker assignment as well; stale, cleared or reissued delegations cannot admit new work. Dependencies remain diagnostics, not a blanket refusal, and are never implicitly started. After start, reread the Ticket and verify the new Worker 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. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index 111160a50..f90b9465b 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -1,6 +1,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required. -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. +Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers to the Orchestrator for its same-delegation tracking; being the assigned Worker does not authorize you to self-report as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. 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 `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. diff --git a/tools/web-ux/browser-tests/ticket_fixture_server.ts b/tools/web-ux/browser-tests/ticket_fixture_server.ts index e028071df..ea0822683 100644 --- a/tools/web-ux/browser-tests/ticket_fixture_server.ts +++ b/tools/web-ux/browser-tests/ticket_fixture_server.ts @@ -5,22 +5,133 @@ import { fixtureDetail, fixturePage, } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; -import type { TicketDetail } from "../../../web/workspace/src/lib/generated/ticket-api.ts"; +import type { + TicketAssignmentTracking, + TicketAssignmentTrackingHistoryEntry, + TicketAssignmentTrackingProjection, + TicketDetail, + TicketQueryItem, + TicketQueryRequest, + WorkspaceTag, +} from "../../../web/workspace/src/lib/generated/ticket-api.ts"; import { emptyLaunchOptions } from "../../../web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts"; const port = Number(Deno.args[0]); const root = Deno.args[1]; const legacy = Deno.args.includes("--legacy"); const tickets = new Map(); const bodies: unknown[] = []; +const retained = new Map(); +const handlingHistory = new Map< + string, + TicketAssignmentTrackingHistoryEntry[] +>(); +const receipts = new Map(); +const tags: WorkspaceTag[] = [ + { + id: "tag-investigation", + name: "Investigation", + workspace_id: "tickets", + deleted: false, + last_operation_key: "create-investigation", + }, + { + id: "tag-ui", + name: "UI", + workspace_id: "tickets", + deleted: false, + last_operation_key: "create-ui", + }, +]; +let assignmentNumber = 10; +let failure: "none" | "unknown_saved" | "unknown_unsaved" | "stale" | "denied" = + "none"; +function remember(row: TicketAssignmentTrackingProjection) { + retained.set(row.assignment_id, row); + if (!handlingHistory.has(row.assignment_id)) { + handlingHistory.set( + row.assignment_id, + row.tracking + ? [{ + at: row.updated_at ?? row.assigned_at, + event_id: row.last_event_id!, + operation_key: `initial-${row.assignment_id}`, + source_context: { actor: "fixture-orchestrator" }, + tracking: structuredClone(row.tracking), + }] + : [], + ); + } +} +function queryItem(t: TicketDetail): TicketQueryItem { + return { + id: t.id, + resource_key: t.resource_key, + title: t.title, + state: t.state, + priority: t.priority, + tags: t.tags, + assignment_tracking: t.assignment_tracking, + closure_operation_key: t.closure_operation_key, + current_closure: t.current_closure, + current_worker: t.current_worker, + content_digest: t.content_digest, + created_at: t.created_at, + updated_at: t.updated_at, + legacy_handoff: t.legacy_handoff, + readiness: t.readiness, + workspace_action_priority: "background", + evidence: t.evidence, + merge_request: t.merge_request, + merge_requests: t.merge_requests, + linked_objective_ids: [], + linked_objective_keys: [], + matched_fields: [], + snippet: t.body, + matching_event: null, + relation_count: t.relations.outgoing.length, + blocker_count: t.relations.blockers.length, + unresolved_blocker_count: t.relations.blockers.length, + unresolved_review_count: 0, + }; +} +function pageSlice(items: T[], cursor: string | null, limit: number) { + const offset = Number(cursor ?? 0); + const slice = items.slice(offset, offset + limit); + const hasMore = offset + slice.length < items.length; + return { + items: slice, + page: { + ...fixturePage(slice.length), + limit, + sort: "updated_desc", + has_more: hasMore, + next_cursor: hasMore ? String(offset + slice.length) : null, + }, + }; +} function contentDigest(value: TicketDetail): string { - return createHash("sha256").update("ticket.content.sha256.v1\0").update(JSON.stringify([ - value.id, value.title, value.body, value.targets.map(({ repository_key, ref_selector, access }) => ({ repository_key, ref_selector, access })), - ])).digest("hex"); + return createHash("sha256").update("ticket.content.sha256.v1\0").update( + JSON.stringify([ + value.id, + value.title, + value.body, + value.targets.map(({ repository_key, ref_selector, access }) => ({ + repository_key, + ref_selector, + access, + })), + ]), + ).digest("hex"); } function ticket(key: string) { if (!tickets.has(key)) { - const value: TicketDetail = fixtureDetail(key); - value.state = "planning"; + const value: TicketDetail = { + ...fixtureDetail(key), + assignment_tracking: [], + }; + value.state = "open"; + value.created_at = "2025-12-31T00:00:00Z"; + value.updated_at = "2026-01-02T00:00:00Z"; value.title = key === "T-2" ? "Read-only investigation" : key === "T-3" @@ -31,11 +142,117 @@ function ticket(key: string) { : key === "T-3" ? "Implement the change, then independent review before merge." : "Return options to the user. Do not draw a conclusion."; - value.action_eligibility.can_queue = true; - value.action_eligibility.queue_tickets = [key]; - value.action_eligibility.can_start_manual_worker = true; + value.action_eligibility.can_assign_orchestrator = true; + value.action_eligibility.can_unassign_orchestrator = true; + value.action_eligibility.can_queue = false; + value.action_eligibility.queue_tickets = []; + const tracking: TicketDetail["assignment_tracking"][number] = { + workspace_id: "tickets", + ticket_id: value.id, + assignment_id: `assignment-${key}`, + role: "orchestrator", + principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + assigned_at: "2026-01-01T00:00:00Z", + assigned_by: "fixture-owner", + current: true, + last_event_id: `handling-${key}`, + updated_at: "2026-01-02T00:00:00Z", + migration_diagnostic: null, + release: null, + tracking: key === "T-2" + ? { + status: "waiting", + waiting_reason: "Awaiting the user's policy choice", + next_action: "Continue after the answer", + progress: null, + result: null, + } + : key === "T-3" + ? { + status: "inprogress", + progress: "Preparing independent review", + next_action: "Request review of the exact source", + waiting_reason: null, + result: null, + } + : { + status: "done", + result: "Compared options; returned them without deciding", + waiting_reason: null, + progress: null, + next_action: null, + }, + }; + value.assignment_tracking = [tracking]; + value.assignments = [{ + assignment_id: tracking.assignment_id, + role: tracking.role, + principal: tracking.principal, + assigned_at: tracking.assigned_at, + assigned_by: tracking.assigned_by, + }]; + value.tags = [structuredClone(tags[0])]; + if (key === "T-3" || key === "T-35") { + value.tags.push(structuredClone(tags[1])); + } + if (key === "T-3") { + value.current_worker = { + assignment_id: "worker-assignment-3", + runtime_id: "embedded", + worker_id: "running-worker", + worker_resource_key: "W-3", + }; + } if (key === "T-2") { - value.targets = [{ repository_key: "main", ref_selector: "develop", access: "read_only" }]; + value.current_worker = { + assignment_id: "worker-assignment-2", + runtime_id: "embedded", + worker_id: "stopped-worker", + worker_resource_key: "W-2", + }; + } + if (key === "T-4") { + value.title = "Completed investigation with retained history"; + value.body = + "Investigate and deliver the complete findings, then judge case closure separately."; + tracking.tracking = { + status: "done", + result: "Delivered the full requested investigation", + waiting_reason: null, + progress: null, + next_action: null, + }; + value.state = "closed"; + value.closure_operation_key = "close-T-4"; + value.current_closure = { + at: "2026-01-02T00:00:00Z", + author: "fixture-owner", + reason: "completed", + explanation: "The requested investigation was delivered", + operation_key: "close-T-4", + references: [], + }; + value.closure_history = [{ + at: value.current_closure.at, + author: value.current_closure.author, + explanation: value.current_closure.explanation, + operation_key: "close-T-4", + references: [], + action: { action: "close", reason: "completed" }, + previous_closure_operation_key: null, + }]; + value.action_eligibility.can_start_manual_worker = false; + } + value.action_eligibility.can_start_manual_worker = value.state === "open"; + if (key === "T-2") { + value.targets = [{ + repository_key: "main", + ref_selector: "develop", + access: "read_only", + }]; } if (key === "T-3") { value.merge_requests = [{ @@ -45,7 +262,10 @@ function ticket(key: string) { selector_from: "work/fixture", selector_to: "develop", review_status: "none", - source_ref_observation: { status: "unavailable", code: "provider_unavailable" }, + source_ref_observation: { + status: "unavailable", + code: "provider_unavailable", + }, current_subject_ref: null, review_subject_ref: null, review_excerpt: null, @@ -53,6 +273,38 @@ function ticket(key: string) { updated_at: "2026-01-02T00:00:00Z", }]; } + remember(tracking); + if (["T-1", "T-4"].includes(key)) { + const previous: TicketAssignmentTrackingProjection = { + ...structuredClone(tracking), + assignment_id: `previous-${key}`, + current: false, + last_event_id: `previous-event-${key}`, + release: { + action: "replaced", + actor: "fixture-owner", + at: "2026-01-01T00:00:00Z", + event_id: `replace-${key}`, + reason: "New request", + replacement_assignment_id: tracking.assignment_id, + last_tracking: structuredClone(tracking.tracking), + operation_key: `replace-op-${key}`, + }, + }; + remember(previous); + if (key === "T-4") { + remember({ + ...structuredClone(previous), + assignment_id: "released-T-4", + release: { + ...previous.release!, + action: "unassigned", + replacement_assignment_id: null, + reason: "Responsibility released without stopping a Worker", + }, + }); + } + } tickets.set(key, value); } const stored = tickets.get(key)!; @@ -68,10 +320,110 @@ function ticket(key: string) { } return value; } +function projectedAssignment( + row: TicketAssignmentTrackingProjection, + workspaceId: string, +) { + const value = { ...structuredClone(row), workspace_id: workspaceId }; + if (workspaceId === "tickets-unknown") { + value.tracking = null; + value.updated_at = null; + value.last_event_id = `legacy-assign-${value.assignment_id}`; + value.migration_diagnostic = + "Historical delegation has unknown handling; no status inferred"; + if (value.release) value.release.last_tracking = null; + } + return value; +} +function projectedTicket(key: string, workspaceId: string) { + const value = ticket(key); + value.tags = value.tags.map((tag) => + tags.find((canonical) => canonical.id === tag.id) ?? tag + ) + .filter((tag) => !tag.deleted).map((tag) => ({ + ...tag, + workspace_id: workspaceId, + })); + if (workspaceId === "tickets-readonly") { + value.action_eligibility = { + blockers: ["Read-only fixture"], + can_assign_orchestrator: false, + can_unassign_orchestrator: false, + can_start_manual_worker: false, + can_queue: false, + queue_tickets: [], + }; + } + value.assignment_tracking = value.assignment_tracking.map((row) => + projectedAssignment(row, workspaceId) + ); + if (workspaceId === "tickets-long") { + value.title += + " — Long investigation title with a full comparison and explicit user confirmation " + .repeat(4); + for (const row of value.assignment_tracking) { + if (row.tracking) { + row.tracking.waiting_reason = + "Waiting for a deliberate user choice, not a Worker state change. " + .repeat(8); + row.tracking.result = + "Returned comparison: no Ticket closure, approval or Worker stop inferred.\n" + .repeat(8); + } + } + } + return value; +} +function workerSummary(workerId: string, state: string) { + return { + worker_id: workerId, + runtime_id: "embedded", + host_id: "fixture-host", + display_name: workerId, + label: workerId, + profile: "builtin:orchestrator", + singleton_key: workerId === "orchestrator-worker" + ? "workspace-orchestrator" + : null, + tags: [], + workspace: { + visibility: "shared", + identity: "tickets", + workspace_id: "tickets", + }, + state, + last_seen_at: "2026-01-02T00:00:00Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "yoi", display_hint: "Fixture Worker" }, + workdir_attachments: [], + diagnostics: [], + }; +} Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); if (url.pathname === "/fixture-state") return Response.json(bodies); + if (url.pathname === "/fixture-snapshot") { + return Response.json({ + tickets: [...tickets.values()], + assignments: [...retained.values()], + receipts: [...receipts.keys()], + }); + } + if (url.pathname === "/fixture-control" && request.method === "POST") { + const control = await request.json(); + if (control.reset) { + tickets.clear(); + retained.clear(); + handlingHistory.clear(); + receipts.clear(); + bodies.length = 0; + failure = "none"; + } + if (control.failure) failure = control.failure; + return Response.json({ configured: true }); + } if (url.pathname === "/api/workspaces") { return Response.json([{ workspace_id: "tickets", @@ -82,7 +434,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { updated_at: "2026-01-02T00:00:00Z", }]); } - const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/); + const match = url.pathname.match( + /^(?:\/api\/w|\/workspaces)\/([^/]+)(\/.*)$/, + ); if (match) { const [, id, path] = match; if (path === "/workspace") { @@ -108,8 +462,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { extension_points: { store: "fixture", event_stream: { status: "ready", note: "fixture", diagnostics: [] }, - host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, - companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + companion_console: { + status: "ready", + note: "fixture", + diagnostics: [], + }, }, }); } @@ -133,9 +495,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (path === "/orchestrator") { return Response.json({ workspace_id: id, - online: false, - disposition: "offline", - worker: null, + online: id === "tickets-running", + disposition: id === "tickets-running" ? "online" : "offline", + worker: workerSummary( + "orchestrator-worker", + id === "tickets-running" ? "Running" : "Stopped", + ), diagnostics: [], }); } @@ -173,6 +538,17 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } return Response.json(options); } + if (path === "/workers/W-2" || path === "/workers/W-3") { + const running = path === "/workers/W-3"; + return Response.json({ + ...workerSummary( + running ? "running-worker" : "stopped-worker", + running ? "Running" : "Stopped", + ), + resource_key: running ? "W-3" : "W-2", + availability: "observed", + }); + } if (path === "/workers") { return Response.json({ workspace_id: id, @@ -185,7 +561,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (path === "/working-directories") { return Response.json({ workspace_id: id, items: [], diagnostics: [] }); } - if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") { + if ( + path === "/protocol/ws" && request.headers.get("upgrade") === "websocket" + ) { const { socket, response } = Deno.upgradeWebSocket(request); socket.onmessage = (event) => { const frame = JSON.parse(String(event.data)); @@ -207,23 +585,141 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { }; return response; } + if (path === "/tickets/query" && request.method !== "POST") { + return Response.json( + { message: "Use POST with TicketQueryRequest JSON" }, + { status: 405 }, + ); + } + if (path === "/tickets/query" && request.method === "POST") { + const body: TicketQueryRequest = await request.json(); + bodies.push({ path, method: request.method, body }); + if (id === "tickets-error") { + return Response.json({ + message: + "Ticket query unavailable — recover by retrying the same read", + }, { status: 503 }); + } + const keys = id === "tickets-empty" + ? [] + : id === "tickets-pages" + ? Array.from({ length: 40 }, (_, index) => `T-${index + 1}`) + : ["T-1", "T-2", "T-3", "T-4"]; + if (id !== "tickets-empty" && tickets.has("T-41")) keys.push("T-41"); + const items = keys.map((key) => projectedTicket(key, id)).filter((t) => { + if ( + body.states?.length && + !body.states.includes(t.state as "open" | "closed") + ) return false; + if ( + body.closure_reason && + (t.state !== "closed" || + JSON.stringify(t.current_closure?.reason) !== + JSON.stringify(body.closure_reason)) + ) return false; + const reasons = t.closure_history.map((entry) => + entry.action.action === "reopen" + ? "" + : typeof entry.action.reason === "string" + ? entry.action.reason + : entry.action.reason.other + ).join(" "); + const query = body.query?.toLocaleLowerCase(); + if ( + query && + !`${t.resource_key} ${t.title} ${t.body} ${ + t.current_closure?.explanation ?? "" + } ${reasons}` + .toLocaleLowerCase().includes(query) + ) return false; + if ( + body.tag_ids?.some((tagId) => !t.tags.some((tag) => tag.id === tagId)) + ) return false; + const rows = t.assignment_tracking.filter((row) => + row.current && + (!body.assigned_principal || + JSON.stringify(row.principal) === + JSON.stringify(body.assigned_principal)) + ); + if (body.assigned_principal && !rows.length) return false; + if ( + body.handling_status && + !rows.some((row) => row.tracking?.status === body.handling_status) + ) return false; + return true; + }).map(queryItem); + return Response.json({ + ...pageSlice(items, body.cursor ?? null, body.limit ?? 30), + record_authority: "fixture", + }); + } + if (path === "/ticket-tags") { + if (request.method === "GET") { + return Response.json(tags.map((tag) => ({ ...tag, workspace_id: id }))); + } + if (id === "tickets-readonly") { + return Response.json({ message: "Permission denied" }, { status: 403 }); + } + const body = await request.json(); + bodies.push({ path, method: request.method, body }); + const existing = tags.find((tag) => tag.id === body.action.tag_id); + if (body.action.kind === "create") { + const tag = { + id: `tag-${tags.length + 1}`, + name: body.action.name, + workspace_id: id, + last_operation_key: body.operation_key, + deleted: false, + }; + tags.push(tag); + return Response.json(tag); + } + if (!existing) { + return Response.json({ message: "Tag unavailable" }, { status: 404 }); + } + if ( + body.action.expected_last_operation_key !== existing.last_operation_key + ) { + return Response.json({ message: "Tag changed; refresh" }, { + status: 409, + }); + } + if (body.action.kind === "rename") existing.name = body.action.name; + if (body.action.kind === "delete") { + existing.deleted = true; + for (const value of tickets.values()) { + value.tags = value.tags.filter((tag) => tag.id !== existing.id); + } + } + existing.last_operation_key = body.operation_key; + return Response.json(existing); + } if (path === "/tickets" && request.method === "POST") { + if (id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } const body = await request.json(); bodies.push({ path, body }); - ticket("T-4"); - const created = tickets.get("T-4")!; + ticket("T-41"); + const created = tickets.get("T-41")!; + created.assignment_tracking = []; + created.assignments = []; + created.tags = []; + created.action_eligibility.can_unassign_orchestrator = false; created.title = body.title; created.body = body.body; created.targets = body.targets ?? []; return Response.json({ id: created.id, - resource_key: "T-4", + resource_key: "T-41", slug: "fixture", status: "Open", }); } if (path === "/tickets") { - const items = ["T-1", "T-2", "T-3"].map(ticket).filter((t) => + const items = ["T-1", "T-2", "T-3", "T-4"].map(ticket).filter((t) => !url.searchParams.has("states") || url.searchParams.get("states")!.split(",").includes(t.state) ); @@ -234,6 +730,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { resource_key: t.resource_key, title: t.title, state: t.state, + tags: t.tags, + closure_operation_key: t.closure_operation_key, + current_closure: t.current_closure, priority: t.priority, updated_at: t.updated_at, queued_at: t.queued_at, @@ -250,35 +749,403 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const tm = path.match(/^\/tickets\/([^/]+)(.*)$/); if (tm) { const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1"; + const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); + const suffix = tm[2]; + if ( + request.method === "GET" && + suffix === "/assignment-tracking/assignments" + ) { + const rows = [...retained.values()].filter((row) => + row.ticket_id === stored.id + ); + const after = url.searchParams.get("after_assignment_id"); + const offset = after + ? rows.findIndex((row) => row.assignment_id === after) + 1 + : 0; + const items = rows.slice( + offset, + offset + Number(url.searchParams.get("limit") ?? 20), + ); + return Response.json({ + items: items.map((row) => projectedAssignment(row, id)), + next_assignment_id: offset + items.length < rows.length + ? items.at(-1)?.assignment_id + : null, + }); + } + if (request.method === "GET" && suffix === "/assignment-diagnostics") { + const assignmentId = url.searchParams.get("assignment_id"); + const row = retained.get(assignmentId ?? ""); + if (!row || row.ticket_id !== stored.id) { + return Response.json({ message: "Assignment unavailable" }, { + status: 404, + }); + } + return Response.json({ + assignment_id: row.assignment_id, + current: row.current, + input_origin_known: id !== "tickets-unknown", + actionable: id !== "tickets-unknown" && row.current && + stored.state === "open" && + row.tracking?.status !== "done", + inputs: id === "tickets-unknown" ? [] : [ + { + input_event_id: `assign-input-${row.assignment_id}`, + delivery_id: key === "T-2" + ? `delivery-input-${row.assignment_id}` + : null, + notification: key === "T-2" + ? "request_persisted" + : "not_reserved", + acceptance_outcome_unknown: key === "T-2", + }, + ...stored.events.filter((entry) => + entry.kind === "assignment_answer" && + entry.attributes.orchestrator_assignment_id === row.assignment_id + ).map((entry) => ({ + input_event_id: entry.event_ref, + delivery_id: null, + notification: "not_reserved", + acceptance_outcome_unknown: false, + })), + ], + next_input_event_id: null, + starts: key === "T-3" && id !== "tickets-unknown" + ? [{ + operation_id: "start-T-3", + claim_state: "committed", + assignment_id: "worker-assignment-3", + runtime_id: "embedded", + worker_id: "running-worker", + actual_processing_unknown: true, + }] + : [], + next_start_operation_id: null, + }); + } + if ( + request.method === "GET" && suffix.startsWith("/assignment-tracking") + ) { + const assignmentId = url.searchParams.get("assignment_id"); + if (suffix === "/assignment-tracking/history") { + const rows = id === "tickets-unknown" + ? [] + : handlingHistory.get(assignmentId ?? "") ?? []; + const after = url.searchParams.get("after_event_id"); + const offset = after + ? rows.findIndex((entry) => entry.event_id === after) + 1 + : 0; + const items = rows.slice( + offset, + offset + Number(url.searchParams.get("limit") ?? 20), + ); + return Response.json({ + items, + next_event_id: offset + items.length < rows.length + ? items.at(-1)?.event_id + : null, + }); + } + return Response.json( + assignmentId + ? [...retained.values()].filter((row) => + row.ticket_id === stored.id && row.assignment_id === assignmentId + ).map((row) => projectedAssignment(row, id)) + : projectedTicket(key, id).assignment_tracking, + ); + } + if ( + request.method !== "GET" && + ["/state", "/close", "/ready", "/queue"].includes(suffix) + ) { + return Response.json({ + message: + "Legacy phase actions are unavailable; use a reasoned case judgment or Assign", + }, { status: 404 }); + } + if (request.method !== "GET" && id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } + if ( + request.method !== "GET" && + [ + "/assignments/orchestrator", + "/assignment-answer", + "/assignment-tracking", + "/case", + "/tags", + ].includes(suffix) + ) { + if (id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } + const body = request.method === "DELETE" + ? Object.fromEntries(url.searchParams) + : await request.json(); + bodies.push({ path, method: request.method, body }); + const operationKey = body.operation_id ?? body.operation_key; + if (!operationKey) { + return Response.json({ message: "Operation identity required" }, { + status: 400, + }); + } + const fingerprint = JSON.stringify({ + path, + method: request.method, + body, + }); + const receipt = receipts.get(operationKey); + if (receipt) { + return receipt.fingerprint === fingerprint + ? Response.json(receipt.result) + : Response.json({ message: "Operation identity conflict" }, { + status: 409, + }); + } + const injected = failure; + failure = "none"; + if (injected === "stale") { + return Response.json({ + message: "Assignment changed; refresh before a new operation", + }, { status: 409 }); + } + if (injected === "denied") { + return Response.json({ message: "Permission denied" }, { + status: 403, + }); + } + if (injected === "unknown_unsaved") { + return Response.json({ message: "Outcome unknown" }, { status: 503 }); + } + const current = stored.assignment_tracking.find((row) => + row.current && row.role === "orchestrator" + ); + let result: unknown; + if (suffix === "/assignments/orchestrator") { + const expected = request.method === "DELETE" + ? body.assignment_id + : body.expected_assignment_id; + if (expected !== (current?.assignment_id ?? null)) { + return Response.json({ message: "Stale assignment identity" }, { + status: 409, + }); + } + const newId = request.method === "DELETE" + ? null + : `assignment-new-${++assignmentNumber}`; + if (current) { + current.current = false; + current.release = { + action: newId ? "replaced" : "unassigned", + actor: "fixture-owner", + at: "2026-01-03T00:00:00Z", + event_id: `release-${assignmentNumber}`, + operation_key: operationKey, + reason: "Explicit fixture operation", + replacement_assignment_id: newId, + last_tracking: structuredClone(current.tracking), + }; + retained.set(current.assignment_id, structuredClone(current)); + } + stored.action_eligibility.can_unassign_orchestrator = newId !== null; + stored.assignment_tracking = []; + stored.assignments = stored.assignments.filter((row) => + row.role !== "orchestrator" + ); + if (newId) { + const row: TicketAssignmentTrackingProjection = { + workspace_id: id, + ticket_id: stored.id, + assignment_id: newId, + assigned_at: "2026-01-03T00:00:00Z", + assigned_by: "fixture-owner", + current: true, + role: "orchestrator", + principal: body.principal, + updated_at: "2026-01-03T00:00:00Z", + last_event_id: `initial-${newId}`, + release: null, + migration_diagnostic: null, + tracking: { + status: "waiting", + waiting_reason: "Initial judgment pending", + next_action: null, + progress: null, + result: null, + }, + }; + stored.assignment_tracking = [row]; + remember(row); + stored.assignments.push({ + assignment_id: newId, + role: row.role, + assigned_at: row.assigned_at, + assigned_by: row.assigned_by, + principal: row.principal, + }); + result = { + workspace_id: id, + ticket_id: stored.id, + assignment: { + workspace_id: id, + ticket_id: stored.id, + ...stored.assignments.at(-1), + }, + }; + } else {result = { + workspace_id: id, + ticket_id: stored.id, + assignment: null, + };} + } else if ( + suffix === "/assignment-answer" || suffix === "/assignment-tracking" + ) { + if ( + !current || current.assignment_id !== body.assignment_id || + current.last_event_id !== body.expected_last_event_id + ) { + return Response.json({ + message: "Stale assignment or observed handling event", + }, { status: 409 }); + } + if (suffix === "/assignment-answer") { + if ( + current.tracking?.status === "done" || stored.state === "closed" + ) { + return Response.json({ + message: "Request a new assignment instead", + }, { status: 409 }); + } + stored.events.push({ + event_ref: `ticket-event:${stored.events.length}`, + kind: "assignment_answer", + body: body.body, + sequence: stored.events.length, + references: [], + attributes: { orchestrator_assignment_id: current.assignment_id }, + }); + stored.event_count = stored.events.length; + result = { + assignment_id: current.assignment_id, + input_event_id: `ticket-event:${stored.events.length - 1}`, + event_index: stored.events.length - 1, + }; + } else { + current.tracking = body.tracking as TicketAssignmentTracking; + current.last_event_id = `handling-update-${ + stored.events.length + + (handlingHistory.get(current.assignment_id)?.length ?? 0) + }`; + current.updated_at = "2026-01-03T00:00:00Z"; + handlingHistory.get(current.assignment_id)!.push({ + at: current.updated_at, + event_id: current.last_event_id, + operation_key: operationKey, + source_context: { actor: "fixture-orchestrator" }, + tracking: structuredClone(current.tracking), + }); + result = current; + } + } else if (suffix === "/tags") { + if ( + JSON.stringify(body.expected_tag_ids.toSorted()) !== + JSON.stringify(stored.tags.map((tag) => tag.id).toSorted()) + ) { + return Response.json({ message: "Tag set conflict" }, { + status: 409, + }); + } + stored.tags = tags.filter((tag) => + !tag.deleted && body.tag_ids.includes(tag.id) + ); + result = stored.tags; + } else { + if ( + body.expected_content_digest !== stored.content_digest || + body.expected_state !== stored.state || + body.expected_closure_operation_key !== stored.closure_operation_key + ) { + return Response.json({ message: "Case judgment conflict" }, { + status: 409, + }); + } + const { action } = body; + stored.closure_history.push({ + action, + at: "2026-01-03T00:00:00Z", + author: "fixture-owner", + explanation: body.explanation, + operation_key: operationKey, + previous_closure_operation_key: stored.closure_operation_key, + references: body.references ?? [], + }); + if (action.action === "reopen") { + stored.state = "open"; + stored.current_closure = null; + } else { + stored.state = "closed"; + stored.closure_operation_key = operationKey; + stored.current_closure = { + at: "2026-01-03T00:00:00Z", + author: "fixture-owner", + explanation: body.explanation, + operation_key: operationKey, + reason: action.reason, + references: body.references ?? [], + }; + } + stored.action_eligibility.can_start_manual_worker = + stored.state === "open"; + result = { + id: stored.id, + resource_key: key, + slug: "fixture", + kind: "task", + labels: [], + tags: stored.tags, + title: stored.title, + priority: stored.priority, + status: stored.state === "open" ? "Open" : "Closed", + workflow_state: stored.state, + workflow_state_explicit: true, + }; + } + receipts.set(operationKey, { + fingerprint, + result: structuredClone(result), + }); + if (injected === "unknown_saved") { + return Response.json({ + message: "Outcome unknown; operation may already be saved", + }, { status: 503 }); + } + return Response.json(result); + } if (request.method !== "GET") { const body = await request.json(); bodies.push({ path, body }); if (url.searchParams.get("failure")) { - return Response.json({ message: "Outcome unknown; refresh before retry" }, { + return Response.json({ + message: "Outcome unknown; refresh before retry", + }, { status: 503, }); } const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); - if (body.expected_content_digest && body.expected_content_digest !== stored.content_digest) { - return Response.json({ message: "Ticket content conflict; refresh" }, { status: 409 }); + if ( + body.expected_content_digest && + body.expected_content_digest !== stored.content_digest + ) { + return Response.json( + { message: "Ticket content conflict; refresh" }, + { status: 409 }, + ); } - if ((tm[2] === "/state" || tm[2] === "/close") && body.expected_state !== stored.state) { - return Response.json({ message: "Ticket state conflict; refresh" }, { status: 409 }); - } - if (tm[2] === "/state") { - stored.state = body.state; - stored.events.push({ - sequence: stored.events.length, - kind: "state_changed", - body: body.body, - reason: body.reason, - from: body.expected_state, - to: body.state, - references: [], - attributes: {}, - event_ref: `event-${stored.events.length}`, - }); - } else if (tm[2] === "/events") { + if (tm[2] === "/events") { stored.events.push({ sequence: stored.events.length, kind: body.role, @@ -287,13 +1154,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { attributes: {}, event_ref: `event-${stored.events.length}`, }); - } else if (tm[2] === "/close") { - stored.state = "closed"; - stored.resolution = body.resolution; - } else if (tm[2] === "/ready") stored.state = "ready"; - else if (tm[2] === "/queue") { - stored.state = "queued"; - return Response.json({ requested_ticket: key, queued_tickets: [key] }); } else if (request.method === "PATCH") { if (body.target) stored.targets = body.target.targets ?? []; if (body.title) stored.title = body.title; @@ -302,12 +1162,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { stored.content_digest = contentDigest(stored); stored.event_count = stored.events.length; } - return Response.json(ticket(key)); + return Response.json(projectedTicket(key, id)); } - return Response.json({ message: "Unavailable fixture resource" }, { status: 404 }); + return Response.json({ message: "Unavailable fixture resource" }, { + status: 404, + }); } const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); - if (relative.startsWith("..")) return new Response("not found", { status: 404 }); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } const mime: Record = { ".html": "text/html", ".js": "text/javascript", @@ -319,7 +1183,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { try { if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); return new Response(await Deno.readFile(file), { - headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" }, + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, }); } catch { return new Response(await Deno.readFile(join(root, "index.html")), { diff --git a/tools/web-ux/browser-tests/ticket_principal_test.ts b/tools/web-ux/browser-tests/ticket_principal_test.ts new file mode 100644 index 000000000..a0144dc61 --- /dev/null +++ b/tools/web-ux/browser-tests/ticket_principal_test.ts @@ -0,0 +1,65 @@ +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { chromium } from "npm:playwright@1.59.1"; +import { loadScenario } from "../src/scenario.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +// Native select and confirmed replacement against the loopback browser fixture. +// happy-dom does not match selected options with :checked (used by Svelte's +// select binding); do not substitute fake component state for this boundary. +Deno.test("principal replacement snapshots the target and observed assignment for exact unknown-outcome retry", async () => { + const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; + const scenario = await loadScenario(scenarioPath); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + scenario.processes![0].args![4] = String(port); + scenario.processes![0].readyUrl = `${base}/health`; + const processes = await startOwnedProcesses( + scenario.processes!, + scenarioPath, + new URL(`../../../target/web-ux/t743-principal/${port}`, import.meta.url).pathname, + [], + ); + let browser: Awaited> | null = null; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-2`); + await page.getByText("Self-reported handling: waiting", { exact: true }).waitFor(); + await page.getByText("Choose responsibility principal", { exact: true }).click(); + await page.getByRole("combobox", { name: "Principal kind", exact: true }).selectOption("user"); + await page.getByLabel("Account identity", { exact: true }).fill("alice"); + await page.getByText("Replace this delegation", { exact: true }).click(); + const sent: string[] = []; + await page.route("**/assignments/orchestrator", async (route) => { + if (route.request().method() === "PUT") { + sent.push(route.request().postData()!); + await route.abort("failed"); + } else await route.continue(); + }); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Replace assignment", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Outcome unknown" }).waitFor(); + assertEquals(sent.length, 1); + const input = JSON.parse(sent[0]); + assertEquals(input.principal, { kind: "user", account_id: "alice" }); + assertEquals(input.expected_assignment_id, "assignment-T-2"); + assert(typeof input.operation_id === "string" && input.operation_id.length > 0); + assert(await page.getByRole("combobox", { name: "Principal kind", exact: true }).isDisabled()); + const retryFailed = page.waitForEvent( + "requestfailed", + (request) => + request.method() === "PUT" && request.url().endsWith("/assignments/orchestrator"), + ); + await page.getByRole("button", { name: "Retry exact operation", exact: true }).click(); + // The alert text is unchanged on retry; synchronize on the actual second + // request failure, not a fixed delay or an unrelated Worker state. + await retryFailed; + assertEquals(sent.length, 2); + assertEquals(sent[1], sent[0]); + } finally { + await browser?.close(); + await stopOwnedProcesses(processes); + } +}); diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index f0928142c..0591becf2 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -1,28 +1,601 @@ import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; -import { chromium } from "npm:playwright@1.59.1"; +import { type Browser, chromium, type Page } from "npm:playwright@1.59.1"; import { loadScenario } from "../src/scenario.ts"; import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; -// Browser boundary over the production static build and synthetic loopback API. -// No Backend, Worker, Repository, review or integration operation is executed. -Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => { - const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; +// Explicit browser boundary: production static build + loopback API fixture. +// These tests prove UI/request semantics, not real Backend delivery or model judgment. +async function withFixture( + run: (browser: Browser, baseUrl: string) => Promise, +) { + const scenarioPath = + new URL("../scenarios/tickets.json", import.meta.url).pathname; const scenario = await loadScenario(scenarioPath); const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const port = (listener.addr as Deno.NetAddr).port; listener.close(); - scenario.baseUrl = `http://127.0.0.1:${port}`; + const baseUrl = `http://127.0.0.1:${port}`; scenario.processes![0].args![4] = String(port); - scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`; - const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname; + scenario.processes![0].readyUrl = `${baseUrl}/health`; + const logs = + new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url) + .pathname; const processes = await startOwnedProcesses( scenario.processes!, scenarioPath, - `${output}/logs`, + logs, [], ); - const browser = await chromium.launch({ headless: true }); + let browser: Browser | null = null; try { + browser = await chromium.launch({ headless: true }); + await run(browser, baseUrl); + } finally { + await browser?.close(); + await stopOwnedProcesses(processes); + } +} +async function detail(base: string, key: string) { + return await (await fetch(`${base}/api/w/tickets/tickets/${key}`)).json(); +} +async function control(base: string, body: unknown) { + const response = await fetch(`${base}/fixture-control`, { + method: "POST", + body: JSON.stringify(body), + }); + assert(response.ok); + await response.json(); +} +function queryResponse( + page: Page, + predicate: (body: Record) => boolean, +) { + return page.waitForResponse((response) => + response.url().endsWith("/tickets/query") && + response.request().method() === "POST" && + predicate(response.request().postDataJSON()) + ); +} + +Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets-pages/tickets`); + await page.locator('.ticket-lane[data-state="open"] .ticket-card').first() + .waitFor(); + assertEquals( + await page.locator('.ticket-lane[data-state="open"] .ticket-card') + .count(), + 30, + ); + assertEquals( + await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) + .count(), + 0, + ); + await page.getByRole("checkbox", { name: "UI", exact: true }).check(); + await page.getByRole("combobox", { + name: "Assigned principal", + exact: true, + }).selectOption("orchestrator"); + const filtered = queryResponse( + page, + (body) => + (body.tag_ids as string[])?.includes("tag-ui") && + (body.assigned_principal as { kind: string })?.kind === + "workspace_agent" && + (body.states as string[]).includes("open"), + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + const response = await filtered; + assertEquals(response.request().postDataJSON(), { + query: null, + states: ["open"], + tag_ids: ["tag-ui"], + assigned_principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor: null, + }); + await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) + .waitFor(); + assertEquals( + await page.locator('.ticket-lane[data-state="open"] .ticket-card') + .count(), + 2, + ); + await page.goto( + `${base}/w/tickets-pages/tickets?case=open&principal=orchestrator&tag=tag-investigation`, + ); + await page.getByRole("button", { name: "Load more open tickets" }) + .waitFor(); + const next = queryResponse(page, (body) => body.cursor === "30"); + await page.getByRole("button", { name: "Load more open tickets" }).click(); + const nextRequest = (await next).request().postDataJSON(); + assertEquals(nextRequest.tag_ids, ["tag-investigation"]); + assertEquals(nextRequest.states, ["open"]); + assertEquals(nextRequest.assigned_principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ }) + .waitFor(); + assertEquals(await page.locator(".ticket-card").count(), 39); + await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`); + await page.getByRole("button", { name: "Load more assignments" }).waitFor(); + const moreHandling = queryResponse( + page, + (body) => body.cursor === "30" && body.handling_status === "done", + ); + await page.getByRole("button", { name: "Load more assignments" }).click(); + const handlingRequest = (await moreHandling).request().postDataJSON(); + assertEquals(handlingRequest.assigned_principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + assertEquals(handlingRequest.states, []); + await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }) + .waitFor(); + assertEquals(await page.locator(".orchestrator-assignment").count(), 38); + await page.getByRole("combobox", { + name: "Handling (self-reported)", + exact: true, + }).selectOption("waiting"); + const waiting = queryResponse( + page, + (body) => body.handling_status === "waiting" && body.cursor === null, + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + await waiting; + await page.locator(".orchestrator-assignment").filter({ + hasText: "Awaiting the user's policy choice", + }).waitFor(); + assertEquals(await page.locator(".orchestrator-assignment").count(), 1); + assertEquals(await page.getByRole("link", { name: /T-1 / }).count(), 0); + }); +}); + +Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.getByText("Close reason filter", { exact: true }).click(); + await page.getByRole("combobox", { + name: "Current close reason", + exact: true, + }).selectOption("completed"); + await page.getByRole("combobox", { name: "Case", exact: true }) + .selectOption("closed"); + const response = queryResponse( + page, + (body) => body.closure_reason === "completed", + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + assertEquals((await response).request().postDataJSON().states, ["closed"]); + await page.locator('.ticket-lane[data-state="open"]').waitFor({ + state: "hidden", + }); + assertEquals(await page.locator(".ticket-card").count(), 1); + assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4"); + await page.getByRole("combobox", { + name: "Current close reason", + exact: true, + }).selectOption("other"); + await page.getByLabel("Close reason text", { exact: true }).fill( + "Superseded plan", + ); + const custom = queryResponse( + page, + (body) => + (body.closure_reason as { other: string })?.other === "Superseded plan", + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + assertEquals((await custom).request().postDataJSON().closure_reason, { + other: "Superseded plan", + }); + await page.getByText("No tickets", { exact: true }).waitFor(); + assertEquals( + (await detail(base, "T-4")).current_closure.reason, + "completed", + ); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((row: { path: string }) => row.path.endsWith("/case")), + ); + }); +}); + +Deno.test("waiting answers retain a delegation, done reissue retains results, and reasoned close/reopen leaves handling separate", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-2`); + await page.getByText("Self-reported handling: waiting", { exact: true }) + .waitFor(); + const before = await detail(base, "T-2"); + const row = before.assignment_tracking[0]; + await page.getByText("Record result or comment", { exact: true }).click(); + await page.getByLabel("Body", { exact: true }).fill( + "Ordinary retained discussion, not an answer input", + ); + const eventResponse = page.waitForResponse((response) => + response.url().endsWith("/events") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Append event", exact: true }) + .click(); + assertEquals((await eventResponse).request().postDataJSON(), { + role: "comment", + body: "Ordinary retained discussion, not an answer input", + }); + await page.getByText("Ordinary retained discussion, not an answer input", { + exact: true, + }).waitFor(); + assertEquals((await detail(base, "T-2")).assignment_tracking[0], row); + await page.getByLabel("Answer / continue this handling", { exact: true }) + .fill("Choose read-only comparison; keep the same delegation"); + const answerResponse = page.waitForResponse((response) => + response.url().endsWith("/assignment-answer") && + response.request().method() === "POST" + ); + await page.getByRole("button", { + name: "Send answer to this assignment", + exact: true, + }).click(); + const answer = (await answerResponse).request().postDataJSON(); + assertEquals(answer.assignment_id, row.assignment_id); + assertEquals(answer.expected_last_event_id, row.last_event_id); + assertEquals( + answer.body, + "Choose read-only comparison; keep the same delegation", + ); + assert(typeof answer.operation_key === "string"); + assertEquals(Object.keys(answer).toSorted(), [ + "assignment_id", + "body", + "expected_last_event_id", + "operation_key", + ]); + await page.getByRole("status").filter({ + hasText: "Answer saved to assignment", + }).waitFor(); + const after = await detail(base, "T-2"); + assertEquals(after.assignment_tracking[0].assignment_id, row.assignment_id); + assertEquals(after.assignment_tracking[0].tracking.status, "waiting"); + assertEquals(after.events.at(-1).kind, "assignment_answer"); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByText("Self-reported handling: done", { exact: true }) + .waitFor(); + const original = (await detail(base, "T-1")).assignment_tracking[0]; + const reissueResponse = page.waitForResponse((response) => + response.url().endsWith("/assignments/orchestrator") && + response.request().method() === "PUT" + ); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + const request = (await reissueResponse).request().postDataJSON(); + assertEquals(request.expected_assignment_id, original.assignment_id); + assertEquals(request.principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + assert(typeof request.operation_id === "string"); + await page.getByText("Self-reported handling: waiting", { exact: true }) + .waitFor(); + const reissued = await detail(base, "T-1"); + assert( + reissued.assignment_tracking[0].assignment_id !== original.assignment_id, + ); + assertEquals(reissued.state, "open"); + await page.getByText("Past assignment and release history", { exact: true }) + .click(); + await page.getByLabel("Assignment identity", { exact: true }).fill( + original.assignment_id, + ); + await page.getByRole("button", { name: "Read assignment", exact: true }) + .click(); + await page.getByText(/Previous result: Compared options/).waitFor(); + await page.getByText(/replaced by fixture-owner/).waitFor(); + await page.goto(`${base}/w/tickets/tickets/T-4`); + await page.getByText("Self-reported handling: done", { exact: true }) + .waitFor(); + await page.locator(".ticket-case-card").getByLabel("Explanation", { + exact: true, + }).fill("Reconsider the case without delegating again"); + const reopenResponse = page.waitForResponse((response) => + response.url().endsWith("/case") && response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Reopen ticket", exact: true }) + .click(); + const reopen = (await reopenResponse).request().postDataJSON(); + assertEquals(reopen.action, { action: "reopen" }); + assertEquals(reopen.expected_state, "closed"); + assertEquals(reopen.expected_closure_operation_key, "close-T-4"); + await page.getByRole("button", { name: "Close ticket", exact: true }) + .waitFor(); + const reopened = await detail(base, "T-4"); + assertEquals(reopened.state, "open"); + assertEquals(reopened.assignment_tracking[0].tracking.status, "done"); + assertEquals(reopened.closure_history.length, 2); + await page.locator(".ticket-case-card").getByLabel("Explanation", { + exact: true, + }).fill("The full requested investigation is delivered"); + await page.locator(".ticket-case-card select").selectOption("completed"); + const closeResponse = page.waitForResponse((response) => + response.url().endsWith("/case") && response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Close ticket", exact: true }) + .click(); + const close = (await closeResponse).request().postDataJSON(); + assertEquals(close.action, { action: "close", reason: "completed" }); + assertEquals(close.expected_state, "open"); + await page.getByRole("button", { name: "Reopen ticket", exact: true }) + .waitFor(); + const closed = await detail(base, "T-4"); + assertEquals(closed.current_closure.reason, "completed"); + assertEquals(closed.tags, reopened.tags); + assertEquals( + closed.assignment_tracking[0].assignment_id, + reopened.assignment_tracking[0].assignment_id, + ); + await page.getByText("Past assignment and release history", { exact: true }) + .click(); + await page.getByLabel("Assignment identity", { exact: true }).fill( + "released-T-4", + ); + await page.getByRole("button", { name: "Read assignment", exact: true }) + .click(); + await page.getByText(/unassigned by fixture-owner/).waitFor(); + await page.getByText( + "Previous result: Delivered the full requested investigation", + { + exact: true, + }, + ).waitFor(); + assert( + await page.getByText("Self-reported handling: done", { exact: true }) + .isVisible(), + ); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((r: { path: string }) => + /\/(queue|ready|state)$/.test(r.path) + ), + ); + }); +}); + +Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.getByRole("button", { name: "Add Ticket", exact: true }).click(); + await page.getByLabel("Title", { exact: true }).fill( + "Repository-free investigation", + ); + await page.getByLabel("Body", { exact: true }).fill( + "Compare options and return without deciding", + ); + const createResponse = page.waitForResponse((response) => + response.url().endsWith("/tickets") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Create Ticket", exact: true }) + .click(); + const create = (await createResponse).request().postDataJSON(); + assertEquals(create.targets, []); + assertEquals(create.workflow_state, "open"); + assertEquals(create.title, "Repository-free investigation"); + await page.getByRole("heading", { + name: "Repository-free investigation", + exact: true, + }).waitFor(); + await page.getByText("No current Orchestrator assignment.", { exact: true }) + .waitFor(); + await page.getByText("Manage Workspace tags", { exact: true }).click(); + await page.getByLabel("Tag name", { exact: true }).fill("Comparisons"); + const tagCreate = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Create tag", exact: true }).click(); + const tagResult = await tagCreate; + const tag = await tagResult.json(); + assertEquals(tagResult.request().postDataJSON().action, { + kind: "create", + name: "Comparisons", + }); + await page.getByText("Edit classification", { exact: true }).click(); + await page.getByRole("checkbox", { name: "Comparisons", exact: true }) + .check(); + await page.getByRole("checkbox", { name: "Investigation", exact: true }) + .check(); + const attach = page.waitForResponse((response) => + response.url().endsWith("/tags") && response.request().method() === "PUT" + ); + await page.getByRole("button", { name: "Save classification", exact: true }) + .click(); + const attachRequest = (await attach).request().postDataJSON(); + assertEquals(attachRequest.expected_tag_ids, []); + assertEquals( + attachRequest.tag_ids.toSorted(), + [tag.id, "tag-investigation"].toSorted(), + ); + await page.locator(".ticket-tag-chips").getByText("Comparisons", { + exact: true, + }).waitFor(); + await page.getByRole("combobox", { name: "Existing tag", exact: true }) + .selectOption(tag.id); + await page.getByLabel("Tag name", { exact: true }).fill("Options"); + const renamed = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Rename tag", exact: true }).click(); + const rename = (await renamed).request().postDataJSON(); + assertEquals(rename.action, { + kind: "rename", + tag_id: tag.id, + expected_last_operation_key: tag.last_operation_key, + name: "Options", + }); + await page.locator(".ticket-tag-chips").getByText("Options", { + exact: true, + }).waitFor(); + const deleted = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Delete tag", exact: true }).click(); + assertEquals((await deleted).request().postDataJSON().action, { + kind: "delete", + tag_id: tag.id, + expected_last_operation_key: rename.operation_key, + }); + await page.locator(".ticket-tag-chips").getByText("Options", { + exact: true, + }).waitFor({ state: "hidden" }); + const ticket = await detail(base, "T-41"); + assertEquals(ticket.state, "open"); + assertEquals(ticket.assignment_tracking, []); + assertEquals(ticket.tags.map((tag: { id: string }) => tag.id), [ + "tag-investigation", + ]); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((row: { path: string }) => + /assignment|\/case$/.test(row.path) + ), + ); + }); +}); + +Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permission denials never claim success", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).waitFor(); + await control(base, { failure: "unknown_saved" }); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + await page.getByRole("alert").filter({ hasText: "Outcome unknown" }) + .waitFor(); + assertEquals( + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).count(), + 0, + ); + const saved = + (await detail(base, "T-1")).assignment_tracking[0].assignment_id; + const beforeRetry = await (await fetch(`${base}/fixture-state`)).json(); + const first = beforeRetry.filter((item: { path: string }) => + item.path.endsWith("/assignments/orchestrator") + ); + assertEquals(first.length, 1); + await page.getByRole("button", { + name: "Retry exact operation", + exact: true, + }).click(); + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).waitFor(); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + const assignmentRequests = requests.filter((item: { path: string }) => + item.path.endsWith("/assignments/orchestrator") + ); + assertEquals(assignmentRequests.length, 2); + assertEquals(assignmentRequests[0].body, assignmentRequests[1].body); + assertEquals( + (await detail(base, "T-1")).assignment_tracking[0].assignment_id, + saved, + ); + for (const failure of ["stale", "denied", "unknown_unsaved"] as const) { + await control(base, { + reset: true, + failure: failure === "stale" ? "none" : failure, + }); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).waitFor(); + let expectedAssignment = "assignment-T-1"; + if (failure === "stale") { + const replacement = await fetch( + `${base}/api/w/tickets/tickets/internal-T-1/assignments/orchestrator`, + { + method: "PUT", + body: JSON.stringify({ + operation_id: "concurrent-replacement", + expected_assignment_id: "assignment-T-1", + principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + }), + }, + ); + assert(replacement.ok); + expectedAssignment = + (await replacement.json()).assignment.assignment_id; + } + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + await page.getByRole("alert").filter({ + hasText: failure === "stale" + ? "Stale observation" + : failure === "denied" + ? "Permission denied" + : "Outcome unknown", + }).waitFor(); + assertEquals( + (await detail(base, "T-1")).assignment_tracking[0].assignment_id, + expectedAssignment, + ); + assertEquals( + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).count(), + 0, + ); + const attempts = await (await fetch(`${base}/fixture-state`)).json(); + assertEquals( + attempts.filter(( + r: { path: string; body: { operation_id?: string } }, + ) => + r.path.endsWith("/assignments/orchestrator") && + r.body.operation_id !== "concurrent-replacement" + ).length, + 1, + ); + } + }); +}); + +Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => { + await withFixture(async (browser, base) => { + const output = + new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url) + .pathname; + await Deno.mkdir(output, { recursive: true }); for (const colorScheme of ["light", "dark"] as const) { for (const width of [1440, 768, 390]) { const context = await browser.newContext({ @@ -32,227 +605,123 @@ Deno.test("Ticket progress, comment results and reopen work without Git while re }); try { const page = await context.newPage(); - const unexpected: string[] = []; - page.on("pageerror", (e) => unexpected.push(String(e))); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); - const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true }); - await addTicket.waitFor(); - const action = await addTicket.boundingBox(); - assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await addTicket.click(); - await page.locator(".ticket-create-form").waitFor(); - assertEquals(await page.locator(".ticket-target-row").count(), 0); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, - ); - await page.locator(".ticket-detail-header").waitFor(); - // Intent comes first at one-column widths, unlike the former controls-first layout. - if (width < 1024) { - const intent = await page.getByRole("heading", { name: "Intent", exact: true }) - .boundingBox(); - const progress = await page.getByRole("heading", { name: "Progress decision" }) - .boundingBox(); - assert(intent && progress && intent.y < progress.y); - } - const state = page.getByRole("combobox", { name: "State", exact: true }); - await state.waitFor(); - assertEquals(await state.locator("option").allTextContents(), [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", - ]); - await state.selectOption("inprogress"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Investigating options directly", - ); - await page.getByRole("button", { name: "Apply state", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor(); - await page.getByText("Record result or comment", { exact: true }).click(); - await page.getByLabel("Body", { exact: true }).fill( - `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, - ); - await page.getByRole("button", { name: "Append event" }).click(); - await page.getByText( - `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, - { exact: true }, - ).waitFor(); - await state.selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Requested comparison returned", - ); - await page.getByLabel("Result, references, and remaining work (optional)").fill( - "Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.", - ); - await page.getByRole("button", { name: "Complete Ticket", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); - assertEquals( - await page.getByRole("heading", { name: "MR requirement evidence" }).count(), - 0, - ); - assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible()); - await state.selectOption("planning"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "User requests another comparison", - ); - await page.getByRole("button", { name: "Reopen / apply state" }).click(); - await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor(); - await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click(); - await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor(); - await page.locator(".ticket-close-card > summary").click(); - await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required"); - await page.getByRole("button", { name: "Close ticket", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor(); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`); - await page.locator(".ticket-detail-header").waitFor(); - assertEquals( - await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(), - "read_only", - ); - assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled()); - await page.getByRole("button", { name: "Mark ready", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor(); - assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled()); - await page.getByRole("button", { name: "Save targets", exact: true }).click(); - await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({ - state: "visible", - }); - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`, - ); - await page.locator(".ticket-detail-header").waitFor(); - await state.selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Progress decision is separate from review", - ); - await page.getByRole("button", { name: "Complete Ticket" }).click(); - await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); - assert(await page.getByText("main: open", { exact: true }).isVisible()); - assert( - await page.getByText("Ticket completion is not MR approval or integration.", { - exact: true, - }).isVisible(), - ); - assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0); - // Member normal operations do not enable owner configuration controls. - assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await Deno.mkdir(output, { recursive: true }); - await page.screenshot({ - path: `${output}/${colorScheme}-${width}-mr-done.png`, - fullPage: true, - }); - assertEquals(unexpected, []); - // Reset synthetic records for the next viewport using the fixture's ordinary state API. - for (const key of ["T-1", "T-2", "T-3"]) { - const detail = - await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`)) - .json(); - await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, { - data: { - state: "planning", - reason: "Fixture reset", - expected_state: detail.state, - expected_content_digest: detail.content_digest, - }, + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + for ( + const [workspace, route, ready] of [ + ["tickets", "tickets", ".ticket-card"], + ["tickets", "orchestrator", ".orchestrator-assignment"], + ["tickets-long", "tickets", ".ticket-card"], + ["tickets-empty", "orchestrator", ".orchestrator-assignments"], + ["tickets-unknown", "orchestrator", ".orchestrator-assignment"], + ["tickets-error", "tickets", ".ticket-lane-page-error"], + ["tickets", "tickets/T-1", ".ticket-detail-header"], + ["tickets", "tickets/T-2", ".ticket-worker-observation strong"], + ["tickets", "tickets/T-3", ".ticket-worker-observation strong"], + ["tickets", "tickets/T-4", ".ticket-detail-header"], + [ + "tickets-readonly", + "tickets/T-2", + ".ticket-worker-observation strong", + ], + [ + "tickets-running", + "tickets/T-2", + ".ticket-worker-observation strong", + ], + ] + ) { + await page.goto(`${base}/w/${workspace}/${route}`); + await page.locator(ready).first().waitFor(); + assertEquals( + await page.evaluate( + "document.documentElement.scrollWidth > innerWidth", + ), + false, + ); + if (workspace === "tickets-empty") { + assert( + await page.getByText( + "No current assignments match these filters.", + { exact: true }, + ).isVisible(), + ); + } + if (workspace === "tickets-error") { + assertEquals( + await page.getByRole("button", { name: "Retry", exact: true }) + .count(), + 2, + ); + assertEquals( + await page.getByText("No tickets", { exact: true }).count(), + 0, + ); + } + if (workspace === "tickets-unknown") { + assertEquals( + await page.getByText("Handling: Unknown (not reported)", { + exact: true, + }).count(), + 4, + ); + } + if (route === "tickets/T-2") { + assert( + await page.getByText("Self-reported handling: waiting", { + exact: true, + }).isVisible(), + ); + assertEquals( + await page.locator(".ticket-worker-observation strong") + .textContent(), + "Stopped", + ); + if (workspace === "tickets-readonly") { + assertEquals( + await page.getByRole("button", { + name: "Replace assignment", + exact: true, + }).count(), + 0, + ); + } + if (workspace === "tickets-running") { + assert( + await page.locator(".ticket-orchestrator-worker").getByText( + "Running", + { exact: true }, + ).isVisible(), + ); + } + } + if (route === "tickets/T-3") { + assert( + await page.getByText("Self-reported handling: inprogress", { + exact: true, + }).isVisible(), + ); + assertEquals( + await page.locator(".ticket-worker-observation strong") + .textContent(), + "Running", + ); + assert( + await page.getByText("main: open", { exact: true }).isVisible(), + ); + } + await page.screenshot({ + path: `${output}/${colorScheme}-${width}-${workspace}-${ + route.replaceAll("/", "-") + }.png`, + fullPage: true, }); } + assertEquals(errors, []); } finally { await context.close(); } } } - const context = await browser.newContext(); - try { - const page = await context.newPage(); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); - await page.getByRole("button", { name: "Add Ticket", exact: true }).click(); - await page.getByLabel("Title", { exact: true }).fill("Repository-free investigation"); - await page.getByLabel("Body", { exact: true }).fill( - "Compare options and return without conclusion; review is unnecessary.", - ); - await page.getByRole("button", { name: "Create Ticket", exact: true }).click(); - await page.getByRole("heading", { name: "Repository-free investigation", exact: true }) - .waitFor(); - assert(await page.getByText("No repository targets.", { exact: true }).isVisible()); - // Generic remote launch: a natural request, not an inferred code/review pipeline. - await page.goto( - `${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`, - ); - await page.locator(".worker-launch-form").waitFor(); - await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote"); - const launches: Record[] = []; - await page.route("**/api/w/tickets/workers", async (route) => { - launches.push(route.request().postDataJSON()); - await route.fulfill({ - status: 403, - json: { error: "permission_denied", message: "Permission denied", diagnostics: [] }, - }); - }); - await page.getByRole("button", { name: "Start Worker", exact: true }).click(); - await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor(); - assertEquals(launches.length, 1); - assertEquals(launches[0].workdir_attachments, []); - assertEquals(launches[0].profile, null); - assertEquals(launches[0].initial_submit, [{ - kind: "text", - content: "Return without conclusion. Review is unnecessary.", - }]); - assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4"); - await page.unroute("**/api/w/tickets/workers"); - for ( - const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [ - 503, - "Outcome unknown or request rejected.", - ]] as const - ) { - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, - ); - await page.locator(".ticket-detail-header").waitFor(); - await page.route( - "**/tickets/*/state", - (route) => route.fulfill({ status, json: { error: "failure", message } }), - ); - await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Keep this draft on rejection", - ); - await page.getByRole("button", { name: "Complete Ticket" }).click(); - await page.getByRole("alert").getByText(message, { exact: false }).waitFor(); - assertEquals( - await page.getByLabel("Reason", { exact: true }).first().inputValue(), - "Keep this draft on rejection", - ); - assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible()); - assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled()); - await page.unroute("**/tickets/*/state"); - } - } finally { - await context.close(); - } - const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json(); - assert( - requests.some((r: { body: { body?: string } }) => - r.body.body?.includes("Remaining: user choice") - ), - ); - assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path))); - } finally { - await browser.close(); - await stopOwnedProcesses(processes); - } + }); }); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 0ffbee433..b2774d68e 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/ticket-principals-dark.json b/tools/web-ux/scenarios/ticket-principals-dark.json new file mode 100644 index 000000000..d91b7a9bc --- /dev/null +++ b/tools/web-ux/scenarios/ticket-principals-dark.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "id": "ticket-principals", + "title": "Observed assignment and responsibility principal choice", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "principal", + "label": "Replace responsibility, not Worker liveness", + "path": "/w/tickets/tickets/T-2", + "goal": "Choose the target for a new observed assignment", + "dataState": "Open + waiting with retained prior assignment", + "ready": { + "kind": "selector", + "selector": ".ticket-handling" + }, + "capturePoints": [ + { + "id": "agent", + "label": "Default Workspace agent", + "interaction": [ + { + "action": "click", + "selector": ".assignment-principal summary" + } + ], + "fullPage": true + }, + { + "id": "account", + "label": "Account target", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Home" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "ArrowDown" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal input" + }, + "fullPage": true + }, + { + "id": "worker", + "label": "Worker principal, separate from actual Orchestrator Worker", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "End" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal label:has-text(\"Runtime identity\")" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/ticket-principals.json b/tools/web-ux/scenarios/ticket-principals.json new file mode 100644 index 000000000..3c662cebd --- /dev/null +++ b/tools/web-ux/scenarios/ticket-principals.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "id": "ticket-principals", + "title": "Observed assignment and responsibility principal choice", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "principal", + "label": "Replace responsibility, not Worker liveness", + "path": "/w/tickets/tickets/T-2", + "goal": "Choose the target for a new observed assignment", + "dataState": "Open + waiting with retained prior assignment", + "ready": { + "kind": "selector", + "selector": ".ticket-handling" + }, + "capturePoints": [ + { + "id": "agent", + "label": "Default Workspace agent", + "interaction": [ + { + "action": "click", + "selector": ".assignment-principal summary" + } + ], + "fullPage": true + }, + { + "id": "account", + "label": "Account target", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Home" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "ArrowDown" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal input" + }, + "fullPage": true + }, + { + "id": "worker", + "label": "Worker principal, separate from actual Orchestrator Worker", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "End" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal label:has-text(\"Runtime identity\")" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/tickets-dark.json b/tools/web-ux/scenarios/tickets-dark.json index 74b0e2ffe..6975e108f 100644 --- a/tools/web-ux/scenarios/tickets-dark.json +++ b/tools/web-ux/scenarios/tickets-dark.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tickets-dark", - "title": "Flexible Ticket progress and optional resources", + "title": "Ticket cases, classification and independent Orchestrator handling", "baseUrl": "http://127.0.0.1:15289", "colorScheme": "dark", "reducedMotion": "reduce", @@ -50,15 +50,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -83,15 +79,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -116,15 +108,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -153,8 +141,8 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", - "dataState": "Three lightweight progress lanes, member normal operations", + "goal": "Server-filter open and closed cases by canonical tags and current principal", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", "ready": { "kind": "selector", "selector": ".ticket-kanban" @@ -162,7 +150,7 @@ "capturePoints": [ { "id": "initial", - "label": "Progress lanes", + "label": "Case list", "fullPage": true }, { @@ -179,6 +167,201 @@ "selector": ".ticket-create-form" }, "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + }, + { + "id": "reason-filter", + "label": "Current close reason versus historical reason search", + "interaction": [ + { + "action": "click", + "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" + }, + { + "action": "click", + "selector": ".ticket-reason-filter summary" + } + ], + "fullPage": true + } + ] + }, + { + "id": "orchestrator", + "path": "/w/tickets/orchestrator", + "label": "Current self-reported handling", + "goal": "Current self-reported handling", + "dataState": "Waiting, inprogress and done independent of case", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current self-reported handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".orchestrator-assignment:last-child", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "closed", + "path": "/w/tickets/tickets/T-4", + "label": "Closed case and retained history", + "goal": "Closed case and retained history", + "dataState": "Completed closure and replaced/unassigned history", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Closed case and retained history", + "fullPage": true + } + ] + }, + { + "id": "permission", + "path": "/w/tickets-readonly/tickets/T-2", + "label": "Read-only assignment", + "goal": "Read-only assignment", + "dataState": "Backend assignment eligibility denied", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only assignment", + "fullPage": true + } + ] + }, + { + "id": "long", + "path": "/w/tickets-long/tickets", + "label": "Long list titles and handling", + "goal": "Long list titles and handling", + "dataState": "Long titles and multiline results", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long list titles and handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "empty", + "path": "/w/tickets-empty/orchestrator", + "label": "No current assignments", + "goal": "No current assignments", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No current assignments", + "fullPage": true + } + ] + }, + { + "id": "error", + "path": "/w/tickets-error/tickets", + "label": "Query failure recovery", + "goal": "Query failure recovery", + "dataState": "503 query, scoped errors and retry", + "ready": { + "kind": "selector", + "selector": ".ticket-lane-page-error" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query failure recovery", + "fullPage": true + } + ] + }, + { + "id": "unknown", + "path": "/w/tickets-unknown/orchestrator", + "label": "Unknown historical handling", + "goal": "Unknown historical handling", + "dataState": "Null handling without inference", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unknown historical handling", + "fullPage": true + } + ] + }, + { + "id": "running", + "path": "/w/tickets-running/tickets/T-2", + "label": "Waiting with running Orchestrator Worker", + "goal": "Waiting with running Orchestrator Worker", + "dataState": "Waiting remains waiting while Worker is running", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Waiting with running Orchestrator Worker", + "fullPage": true } ] } diff --git a/tools/web-ux/scenarios/tickets.json b/tools/web-ux/scenarios/tickets.json index 3b94ce688..2605ff64d 100644 --- a/tools/web-ux/scenarios/tickets.json +++ b/tools/web-ux/scenarios/tickets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tickets", - "title": "Flexible Ticket progress and optional resources", + "title": "Ticket cases, classification and independent Orchestrator handling", "baseUrl": "http://127.0.0.1:15289", "colorScheme": "light", "reducedMotion": "reduce", @@ -50,15 +50,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -83,15 +79,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -116,15 +108,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -153,8 +141,8 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", - "dataState": "Three lightweight progress lanes, member normal operations", + "goal": "Server-filter open and closed cases by canonical tags and current principal", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", "ready": { "kind": "selector", "selector": ".ticket-kanban" @@ -162,7 +150,7 @@ "capturePoints": [ { "id": "initial", - "label": "Progress lanes", + "label": "Case list", "fullPage": true }, { @@ -179,6 +167,201 @@ "selector": ".ticket-create-form" }, "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + }, + { + "id": "reason-filter", + "label": "Current close reason versus historical reason search", + "interaction": [ + { + "action": "click", + "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" + }, + { + "action": "click", + "selector": ".ticket-reason-filter summary" + } + ], + "fullPage": true + } + ] + }, + { + "id": "orchestrator", + "path": "/w/tickets/orchestrator", + "label": "Current self-reported handling", + "goal": "Current self-reported handling", + "dataState": "Waiting, inprogress and done independent of case", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current self-reported handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".orchestrator-assignment:last-child", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "closed", + "path": "/w/tickets/tickets/T-4", + "label": "Closed case and retained history", + "goal": "Closed case and retained history", + "dataState": "Completed closure and replaced/unassigned history", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Closed case and retained history", + "fullPage": true + } + ] + }, + { + "id": "permission", + "path": "/w/tickets-readonly/tickets/T-2", + "label": "Read-only assignment", + "goal": "Read-only assignment", + "dataState": "Backend assignment eligibility denied", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only assignment", + "fullPage": true + } + ] + }, + { + "id": "long", + "path": "/w/tickets-long/tickets", + "label": "Long list titles and handling", + "goal": "Long list titles and handling", + "dataState": "Long titles and multiline results", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long list titles and handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "empty", + "path": "/w/tickets-empty/orchestrator", + "label": "No current assignments", + "goal": "No current assignments", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No current assignments", + "fullPage": true + } + ] + }, + { + "id": "error", + "path": "/w/tickets-error/tickets", + "label": "Query failure recovery", + "goal": "Query failure recovery", + "dataState": "503 query, scoped errors and retry", + "ready": { + "kind": "selector", + "selector": ".ticket-lane-page-error" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query failure recovery", + "fullPage": true + } + ] + }, + { + "id": "unknown", + "path": "/w/tickets-unknown/orchestrator", + "label": "Unknown historical handling", + "goal": "Unknown historical handling", + "dataState": "Null handling without inference", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unknown historical handling", + "fullPage": true + } + ] + }, + { + "id": "running", + "path": "/w/tickets-running/tickets/T-2", + "label": "Waiting with running Orchestrator Worker", + "goal": "Waiting with running Orchestrator Worker", + "dataState": "Waiting remains waiting while Worker is running", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Waiting with running Orchestrator Worker", + "fullPage": true } ] } diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5a25ed37b..1cef3e768 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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/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": "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/api/ticket-operations.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", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index d40e0565a..e01e53961 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 307f65277..a8d0103f6 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 516e54f07..fa8a2d416 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 7c9829198..1e14cc770 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 @@ -87,6 +87,16 @@ export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | export type TicketActionEligibility = { blockers: Array; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array; }; +export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; }; + +export type TicketAssignmentAnswerReceipt = { assignment_id: string; event_index: number; input_event_id: string; }; + +export type TicketAssignmentDiagnosticsPage = { actionable: boolean; assignment_id: string; current: boolean; input_origin_known: boolean; inputs: Array; next_input_event_id?: string | null; next_start_operation_id?: string | null; starts: Array; }; + +export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "request_persisted" | "runtime_accepted"; + +export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; }; + export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; @@ -97,6 +107,8 @@ export type TicketAssignmentReleaseAction = "unassigned" | "replaced"; export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor"; +export type TicketAssignmentStartDiagnostic = { actual_processing_unknown: boolean; assignment_id?: string | null; claim_state: string; operation_id: string; runtime_id?: string | null; worker_id?: string | null; }; + export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; export type TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; }; @@ -111,6 +123,8 @@ export type TicketAssignmentTrackingStatus = "waiting" | "inprogress" | "done"; export type TicketAssignmentTrackingUpdate = { assignment_id: string; expected_last_event_id: string; operation_key: string; tracking: TicketAssignmentTracking; }; +export type TicketAssignmentsPage = { items: Array; next_assignment_id?: string | null; }; + export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence"; export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; }; @@ -155,7 +169,7 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; i export type TicketQueryItem = { assignment_tracking: Array; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array; linked_objective_keys: Array; matched_fields: Array; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; }; -export type TicketQueryRequest = { attention?: Array; cursor?: string | null; event_kinds?: Array; evidence?: Array; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array; tag_ids?: Array; tag_ids_any?: Array; tag_ids_none?: Array; updated_after?: string | null; updated_before?: string | null; }; +export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array; evidence?: Array; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array; tag_ids?: Array; tag_ids_any?: Array; tag_ids_none?: Array; updated_after?: string | null; updated_before?: string | null; }; export type TicketQueryResponse = { items: Array; page: QueryPage; record_authority: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 19d7b3edb..6ce0504f5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 3868137c5..27665034f 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 6b2135702..ade684b98 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -41,6 +41,8 @@ import type { TicketListItemSummary, TicketListResponse, TicketMergeRequestSummary, + TicketQueryItem, + TicketQueryResponse, TicketQueueOutcome, TicketRef, TicketReference, @@ -335,7 +337,10 @@ function caseProjection(item: Record, label: string) { }; } -function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag { +export function parseWorkspaceTag( + value: unknown, + label = "Workspace tag", +): WorkspaceTag { const item = object(value, label); exact(item, [ "workspace_id", @@ -652,7 +657,7 @@ function parseTicketRoleAssignmentRecord( }; } -function parseAssignmentTracking( +export function parseAssignmentTracking( value: unknown, label: string, ): TicketAssignmentTracking { @@ -722,7 +727,7 @@ function parseAssignmentRelease( }; } -function parseAssignmentTrackingProjection( +export function parseAssignmentTrackingProjection( value: unknown, label: string, ): TicketAssignmentTrackingProjection { @@ -1750,3 +1755,152 @@ export function parseMergeRequestDetailResponse( workspace_id: string(item.workspace_id, `${label}.workspace_id`), }; } + +export function parseTicketQueryResponse(value: unknown): TicketQueryResponse { + const label = "Ticket query response"; + const page = object(value, label); + exact(page, ["items", "page", "record_authority"], label); + return { + page: parseQueryPage(page.page, `${label}.page`), + record_authority: string( + page.record_authority, + `${label}.record_authority`, + ), + items: array( + page.items, + `${label}.items`, + (value, label): TicketQueryItem => { + const item = object(value, label); + exact(item, [ + "assignment_tracking", + "blocker_count", + "closure_operation_key", + "content_digest", + "created_at", + "current_closure", + "current_worker", + "evidence", + "id", + "legacy_handoff", + "linked_objective_ids", + "linked_objective_keys", + "matched_fields", + "matching_event", + "merge_request", + "merge_requests", + "priority", + "readiness", + "relation_count", + "resource_key", + "snippet", + "state", + "tags", + "title", + "unresolved_blocker_count", + "unresolved_review_count", + "updated_at", + "workspace_action_priority", + ], label); + return { + id: string(item.id, `${label}.id`), + resource_key: string(item.resource_key, `${label}.resource_key`), + title: string(item.title, `${label}.title`), + state: parseTicketState(item.state, `${label}.state`), + priority: string(item.priority, `${label}.priority`), + assignment_tracking: array( + item.assignment_tracking, + `${label}.assignment_tracking`, + parseAssignmentTrackingProjection, + ), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), + blocker_count: integer(item.blocker_count, `${label}.blocker_count`), + unresolved_blocker_count: integer( + item.unresolved_blocker_count, + `${label}.unresolved_blocker_count`, + ), + unresolved_review_count: integer( + item.unresolved_review_count, + `${label}.unresolved_review_count`, + ), + relation_count: integer( + item.relation_count, + `${label}.relation_count`, + ), + content_digest: string( + item.content_digest, + `${label}.content_digest`, + ), + created_at: optionalNullableString( + item.created_at, + `${label}.created_at`, + ), + updated_at: optionalNullableString( + item.updated_at, + `${label}.updated_at`, + ), + closure_operation_key: optionalNullableString( + item.closure_operation_key, + `${label}.closure_operation_key`, + ), + current_closure: item.current_closure == null + ? item.current_closure as null | undefined + : parseClosure(item.current_closure, `${label}.current_closure`), + current_worker: item.current_worker == null + ? item.current_worker as null | undefined + : parseTicketAssignmentSummary( + item.current_worker, + `${label}.current_worker`, + ), + evidence: parseTicketEvidenceSummary( + item.evidence, + `${label}.evidence`, + ), + legacy_handoff: item.legacy_handoff == null + ? item.legacy_handoff as null | undefined + : parseLegacyHandoff( + item.legacy_handoff, + `${label}.legacy_handoff`, + ), + linked_objective_ids: strings( + item.linked_objective_ids, + `${label}.linked_objective_ids`, + ), + linked_objective_keys: strings( + item.linked_objective_keys, + `${label}.linked_objective_keys`, + ), + matched_fields: strings( + item.matched_fields, + `${label}.matched_fields`, + ), + matching_event: item.matching_event == null + ? item.matching_event as null | undefined + : parseTicketEvidenceEvent( + item.matching_event, + `${label}.matching_event`, + ), + merge_request: item.merge_request == null + ? item.merge_request as null | undefined + : parseTicketMergeRequestSummary( + item.merge_request, + `${label}.merge_request`, + ), + merge_requests: array( + item.merge_requests, + `${label}.merge_requests`, + parseTicketMergeRequestSummary, + ), + readiness: optionalNullableString( + item.readiness, + `${label}.readiness`, + ), + snippet: optionalNullableString(item.snippet, `${label}.snippet`), + workspace_action_priority: string( + item.workspace_action_priority, + `${label}.workspace_action_priority`, + ), + }; + }, + ), + }; +} diff --git a/web/workspace/src/lib/workspace/api/ticket-operations.test.ts b/web/workspace/src/lib/workspace/api/ticket-operations.test.ts new file mode 100644 index 000000000..a04b9aaac --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-operations.test.ts @@ -0,0 +1,275 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function ordered(value: unknown): unknown { + return Array.isArray(value) + ? value.map(ordered) + : value && typeof value === "object" + ? Object.fromEntries( + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(( + [key, item], + ) => [key, ordered(item)]), + ) + : value; +} +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(ordered(actual)) !== JSON.stringify(ordered(expected))) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} +function assertThrows(operation: () => unknown): void { + try { + operation(); + } catch { + return; + } + throw new Error("Expected rejection"); +} +function assertStringIncludes(actual: string, expected: string): void { + if (!actual.includes(expected)) { + throw new Error(`${actual} does not include ${expected}`); + } +} +import type { + TicketAssignmentTrackingProjection, + TicketDetail, +} from "#lib/generated/ticket-api.ts"; +import { + answerOperation, + assignmentOperation, + observeOperation, + operationError, + parseAnswerReceipt, + parseAssignmentDiagnostics, + parseAssignmentsPage, + parseTrackingHistory, +} from "./ticket-operations.ts"; + +const current: TicketAssignmentTrackingProjection = { + workspace_id: "team one", + ticket_id: "ticket/one", + assignment_id: "assignment-1", + role: "orchestrator", + current: true, + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "account/user", + assigned_at: "2026-01-01T00:00:00Z", + tracking: { + status: "waiting", + waiting_reason: "User decision needed", + next_action: "Read answer", + }, + last_event_id: "event-1", + updated_at: "2026-01-02T00:00:00Z", +}; +function observed(): Pick { + return { + assignments: [{ + assignment_id: current.assignment_id, + role: "orchestrator", + principal: current.principal, + assigned_at: current.assigned_at, + assigned_by: current.assigned_by, + }], + }; +} +Deno.test("assignment retry retains the exact observed pointer, operation key and payload after new data arrives", () => { + const old = observed(); + const operation = assignmentOperation( + "team one", + "ticket/one", + old, + "operation-1", + ); + old.assignments[0].assignment_id = "replacement"; + assertEquals(JSON.parse(operation.body!), { + operation_id: "operation-1", + expected_assignment_id: "assignment-1", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + }); + assertEquals( + operation.path, + "/api/w/team%20one/tickets/ticket%2Fone/assignments/orchestrator", + ); + assertEquals(Object.isFrozen(operation), true); + const fresh = assignmentOperation( + "team one", + "ticket/one", + old, + "operation-2", + ); + assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement"); + const clear = assignmentOperation( + "team one", + "ticket/one", + old, + "release", + true, + ); + assertStringIncludes( + clear.path, + "operation_id=release&assignment_id=replacement", + ); + assertEquals(clear.method, "DELETE"); + assertThrows(() => + assignmentOperation("team", "ticket", { assignments: [] }, "clear", true) + ); +}); +Deno.test("waiting answers carry the same assignment and observed event without status or Assign mutation", () => { + const operation = answerOperation( + "team one", + "ticket/one", + current, + "My answer", + "answer-key", + ); + assertEquals(JSON.parse(operation.body!), { + assignment_id: "assignment-1", + expected_last_event_id: "event-1", + body: "My answer", + operation_key: "answer-key", + }); + assertEquals( + operation.path, + "/api/w/team%20one/tickets/ticket%2Fone/assignment-answer", + ); + for ( + const unavailable of [{ ...current, current: false }, { + ...current, + last_event_id: null, + }, { ...current, tracking: { status: "done" as const } }] + ) { + assertThrows(() => + answerOperation("team", "ticket", unavailable, "answer", "new-op") + ); + } +}); +Deno.test("operation snapshots are serialized once rather than silently rebased for exact retry", () => { + const body = { + operation_key: "saved", + tag_ids: ["stable-id"], + expected_tag_ids: [] as string[], + }; + const operation = observeOperation("/tags", "PUT", body); + body.tag_ids[0] = "renamed-id"; + assertEquals(JSON.parse(operation.body!).tag_ids, ["stable-id"]); +}); +Deno.test("delivery diagnostics keep saved notification, acceptance uncertainty and actual processing separate", () => { + const wire = { + assignment_id: "assignment-1", + current: true, + input_origin_known: true, + actionable: true, + inputs: [{ + input_event_id: "input-1", + delivery_id: "delivery-1", + notification: "request_persisted" as const, + acceptance_outcome_unknown: true, + }], + starts: [{ + operation_id: "start-1", + claim_state: "pending", + assignment_id: null, + runtime_id: null, + worker_id: null, + actual_processing_unknown: true, + }], + next_input_event_id: null, + next_start_operation_id: null, + }; + assertEquals(parseAssignmentDiagnostics(wire), wire); + assertThrows(() => + parseAssignmentDiagnostics({ ...wire, rendered_content: "must not leak" }) + ); + assertThrows(() => + parseAssignmentDiagnostics({ + ...wire, + inputs: [{ ...wire.inputs[0], notification: "done" }], + }) + ); + assertThrows(() => + parseAssignmentDiagnostics({ + ...wire, + inputs: Array(101).fill(wire.inputs[0]), + }) + ); +}); +Deno.test("retained assignment page preserves result and release rather than rewriting handling to done", () => { + const row = { + ...current, + current: false, + release: { + action: "replaced", + event_id: "release-event", + actor: "account/user", + at: current.assigned_at, + replacement_assignment_id: "assignment-2", + last_tracking: current.tracking, + }, + }; + const page = parseAssignmentsPage({ + items: [row], + next_assignment_id: "assignment-1", + }); + assertEquals(page.items[0].tracking?.status, "waiting"); + assertEquals(page.items[0].release?.action, "replaced"); + assertThrows(() => + parseAssignmentsPage({ + items: Array(101).fill(row), + next_assignment_id: null, + }) + ); + assertThrows(() => + parseAssignmentsPage({ + items: [row], + next_assignment_id: null, + guessed_status: "done", + }) + ); + assertThrows(() => parseTrackingHistory({ items: [], next_event_id: 1 })); +}); +Deno.test("answer receipt rejects unsafe event indices and extra inferred result fields", () => { + assertEquals( + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 1, + }), + { assignment_id: "a", input_event_id: "event:1", event_index: 1 }, + ); + assertThrows(() => + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 2 ** 54, + }) + ); + assertThrows(() => + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 1, + status: "inprogress", + }) + ); +}); +Deno.test("conflict, permission, unavailable and unknown errors never claim successful new delegation", () => { + assertStringIncludes( + operationError(new Error("request failed (409)")), + "Stale observation", + ); + assertStringIncludes( + operationError(new Error("request failed (403)")), + "Permission denied", + ); + assertStringIncludes( + operationError(new Error("request failed (404)")), + "unavailable", + ); + assertStringIncludes( + operationError(new Error("network response lost")), + "Outcome unknown", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-operations.ts b/web/workspace/src/lib/workspace/api/ticket-operations.ts new file mode 100644 index 000000000..990353a11 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-operations.ts @@ -0,0 +1,293 @@ +import type { + SetTicketRoleAssignmentRequest, + TicketAssignmentAnswer, + TicketAssignmentAnswerReceipt, + TicketAssignmentDiagnosticsPage, + TicketAssignmentsPage, + TicketAssignmentTrackingHistoryPage, + TicketAssignmentTrackingProjection, + TicketDetail, + WorkspaceTag, +} from "#lib/generated/ticket-api.ts"; +import { workspaceApiPath } from "./http.ts"; +import { + parseAssignmentTracking, + parseAssignmentTrackingProjection, + parseWorkspaceTag, +} from "./ticket-browser.ts"; + +export function parseTags(value: unknown): WorkspaceTag[] { + if (!Array.isArray(value) || value.length > 1000) { + throw new Error("Tag list is invalid or unbounded"); + } + return value.map((tag, index) => parseWorkspaceTag(tag, `Tags[${index}]`)); +} +export function parseTracking( + value: unknown, +): TicketAssignmentTrackingProjection[] { + if (!Array.isArray(value) || value.length > 1000) { + throw new Error("Assignment list is invalid or unbounded"); + } + return value.map((item, index) => + parseAssignmentTrackingProjection(item, `Assignments[${index}]`) + ); +} +export function parseTrackingHistory( + value: unknown, +): TicketAssignmentTrackingHistoryPage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("History must be an object"); + } + const page = value as Record; + if ( + Object.keys(page).some((key) => + !["items", "next_event_id"].includes(key) + ) || !Array.isArray(page.items) || page.items.length > 100 + ) throw new Error("History page is invalid"); + const items = page.items.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("History entry must be an object"); + } + const item = value as Record; + if ( + Object.keys(item).some((key) => + !["at", "event_id", "operation_key", "source_context", "tracking"] + .includes(key) + ) + ) throw new Error("Unexpected history field"); + for (const key of ["at", "event_id", "operation_key"]) { + if ( + typeof item[key] !== "string" || (item[key] as string).length > 1024 + ) throw new Error(`History ${key} is invalid`); + } + return { + at: item.at as string, + event_id: item.event_id as string, + operation_key: item.operation_key as string, + source_context: item.source_context, + tracking: parseAssignmentTracking(item.tracking, `History[${index}]`), + }; + }); + if ( + page.next_event_id != null && + (typeof page.next_event_id !== "string" || page.next_event_id.length > 1024) + ) throw new Error("History cursor is invalid"); + return { + items, + next_event_id: page.next_event_id as string | null | undefined, + }; +} +export function trackingPath(workspaceId: string, ticketId: string): string { + return `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${ + encodeURIComponent(ticketId) + }/assignment-tracking`; +} +export type ObservedOperation = Readonly< + { path: string; method: "POST" | "PUT" | "DELETE"; body?: string } +>; +// Keep the exact serialized input and comparison subject for explicit retry. A new +// observation must never silently rebase an ambiguous operation onto a new assignment. +export function observeOperation( + path: string, + method: ObservedOperation["method"], + body?: unknown, +): ObservedOperation { + return Object.freeze({ + path, + method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} +export function assignmentOperation( + workspaceId: string, + ticketId: string, + observed: Pick, + operationId: string, + clear = false, + principal: SetTicketRoleAssignmentRequest["principal"] = { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, +): ObservedOperation { + const current = observed.assignments.find((row) => + row.role === "orchestrator" + ); + const path = workspaceApiPath( + workspaceId, + `/tickets/${encodeURIComponent(ticketId)}/assignments/orchestrator`, + ); + if (clear) { + if (!current) throw new Error("No observed current assignment to release"); + return observeOperation( + `${path}?${new URLSearchParams({ + operation_id: operationId, + assignment_id: current.assignment_id, + })}`, + "DELETE", + ); + } + const request: SetTicketRoleAssignmentRequest = { + operation_id: operationId, + expected_assignment_id: current?.assignment_id ?? null, + principal, + }; + return observeOperation(path, "PUT", request); +} +export function operationError(error: unknown): string { + const text = error instanceof Error ? error.message : String(error); + if (/\(409\)/.test(text)) { + return `Stale observation / conflict. Refresh and reconcile; do not overwrite or retry as a new operation. ${text}`; + } + if (/\(401\)|\(403\)/.test(text)) { + return `Permission denied. No access was granted. ${text}`; + } + if (/\(404\)/.test(text)) { + return `Resource or assignment unavailable. ${text}`; + } + if (/\(400\)|\(422\)/.test(text)) return `Input rejected. ${text}`; + return `Outcome unknown. This may already be saved; retain the same operation key for exact retry. ${text}`; +} + +function record(value: unknown, keys: string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected API object"); + } + const item = value as Record; + if (Object.keys(item).some((key) => !keys.includes(key))) { + throw new Error("Unexpected API field"); + } + return item; +} +function text(value: unknown): string { + if (typeof value !== "string" || value.length > 16384) { + throw new Error("Expected bounded string"); + } + return value; +} +function optionalText(value: unknown): string | null | undefined { + return value == null ? value as null | undefined : text(value); +} +function flag(value: unknown): boolean { + if (typeof value !== "boolean") throw new Error("Expected boolean"); + return value; +} +function pageItems(value: unknown, parse: (value: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > 100) { + throw new Error("Expected bounded page"); + } + return value.map(parse); +} +export function parseAssignmentsPage(value: unknown): TicketAssignmentsPage { + const page = record(value, ["items", "next_assignment_id"]); + return { + items: pageItems( + page.items, + (item) => parseAssignmentTrackingProjection(item, "Assignment page"), + ), + next_assignment_id: optionalText(page.next_assignment_id), + }; +} +export function parseAnswerReceipt( + value: unknown, +): TicketAssignmentAnswerReceipt { + const item = record(value, [ + "assignment_id", + "input_event_id", + "event_index", + ]); + if ( + !Number.isSafeInteger(item.event_index) || (item.event_index as number) < 0 + ) throw new Error("Answer event index invalid"); + return { + assignment_id: text(item.assignment_id), + input_event_id: text(item.input_event_id), + event_index: item.event_index as number, + }; +} +export function answerOperation( + workspaceId: string, + ticketId: string, + observed: TicketAssignmentTrackingProjection, + body: string, + operationKey: string, +): ObservedOperation { + if ( + !observed.current || !observed.last_event_id || !observed.tracking || + observed.tracking.status === "done" + ) throw new Error("Read actionable current assignment before answering"); + const request: TicketAssignmentAnswer = { + assignment_id: observed.assignment_id, + expected_last_event_id: observed.last_event_id, + body, + operation_key: operationKey, + }; + return observeOperation( + workspaceApiPath( + workspaceId, + `/tickets/${encodeURIComponent(ticketId)}/assignment-answer`, + ), + "POST", + request, + ); +} +export function parseAssignmentDiagnostics( + value: unknown, +): TicketAssignmentDiagnosticsPage { + const page = record(value, [ + "assignment_id", + "current", + "input_origin_known", + "actionable", + "inputs", + "next_input_event_id", + "starts", + "next_start_operation_id", + ]); + return { + assignment_id: text(page.assignment_id), + current: flag(page.current), + input_origin_known: flag(page.input_origin_known), + actionable: flag(page.actionable), + next_input_event_id: optionalText(page.next_input_event_id), + next_start_operation_id: optionalText(page.next_start_operation_id), + inputs: pageItems(page.inputs, (value) => { + const item = record(value, [ + "input_event_id", + "delivery_id", + "notification", + "acceptance_outcome_unknown", + ]); + if ( + !["not_reserved", "reserved", "request_persisted", "runtime_accepted"] + .includes(item.notification as string) + ) throw new Error("Unexpected notification state"); + return { + input_event_id: text(item.input_event_id), + delivery_id: optionalText(item.delivery_id), + notification: item + .notification as TicketAssignmentDiagnosticsPage["inputs"][number][ + "notification" + ], + acceptance_outcome_unknown: flag(item.acceptance_outcome_unknown), + }; + }), + starts: pageItems(page.starts, (value) => { + const item = record(value, [ + "operation_id", + "claim_state", + "assignment_id", + "runtime_id", + "worker_id", + "actual_processing_unknown", + ]); + return { + operation_id: text(item.operation_id), + claim_state: text(item.claim_state), + assignment_id: optionalText(item.assignment_id), + runtime_id: optionalText(item.runtime_id), + worker_id: optionalText(item.worker_id), + actual_processing_unknown: flag(item.actual_processing_unknown), + }; + }), + }; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index df1dc3d0f..e234d9a51 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -392,16 +392,13 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets, Objectives, and Merge Requests should each be a single primary sidebar link", ); assert( - ticketsLoad.includes("loadLane(fetch, workspaceId") && - ticketsLoad.includes('limit: "30"') && - ticketsLoad.includes('states: states.join(",")') && - ticketsLoad.includes("/tickets?${search}") && - !ticketsLoad.includes("/tickets/query") && + ticketsLoad.includes("ticketQueryRequest(filters, [state])") && + ticketsLoad.includes("/tickets/query") && + ticketsLoad.includes('method: "POST"') && ticketsPage.includes('class="ticket-kanban"') && ticketsPage.includes("laneState") && - ticketsPage.includes("loadMore(lane.id)") && - ticketsPage.includes("handleLaneScroll(event, lane.id)"), - "Tickets list should fetch lightweight paginated summaries for each Kanban lane", + ticketsPage.includes("loadMore(lane.id)"), + "Tickets list must apply shared server query filters before paging case lanes", ); assert( ticketPanelModel.includes('label: "Open"') && @@ -428,14 +425,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as assert( ticketDetailLoad.includes("/repositories") && ticketDetailPage.includes("ticketCaseRequest(ticket, action") && - ticketDetailPage.includes("async function queueTicket") && + ticketDetailPage.includes("TicketHandling") && ticketDetailPage.includes("const path = ticketPath") && - ticketDetailPage.includes("`${path}/queue`") && + !ticketDetailPage.includes("queueTicket") && + !ticketDetailPage.includes("markReady") && !ticketDetailPage.includes("/merge-request/merge") && ticketDetailPage.includes("mergeRequest.selector_from") && ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") && ticketDetailPage.includes("mergeRequestPagePath") && - ticketDetailPage.includes("await updateTicketCase(") && + ticketDetailPage.includes("runCaseDecision") && + ticketDetailPage.includes("TicketTags") && ticketDetailPage.includes("mutateAssignment") && ticketDetailPage.includes("can_start_manual_worker") && ticketDetailPage.includes("ticket.relations.outgoing"), diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 501890012..1818609cb 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -49,6 +49,7 @@ export function fixtureDetail(key = "T-101") { artifacts: [], artifact_count: 0, assignments: [], + assignment_tracking: [], assignment_diagnostics: [], events: [], event_count: 0, diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte new file mode 100644 index 000000000..de6df2070 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte @@ -0,0 +1,219 @@ + +
+

Orchestrator handling

All handling
+ {#if current} +

Self-reported handling: {current.tracking?.status ?? "unknown"}

+

Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}

+

Assignment {current.assignment_id} · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}

+ {#if current.tracking} +
+ {#if current.tracking.waiting_reason}
Waiting reason
{current.tracking.waiting_reason}
{/if} + {#if current.tracking.progress}
Progress
{current.tracking.progress}
{/if} + {#if current.tracking.next_action}
Next action
{current.tracking.next_action}
{/if} + {#if current.tracking.result}
Result of this handling
{current.tracking.result}
{/if} +
+ {/if} + {#if current.updated_at}

Updated {formatDate(current.updated_at)}

{/if} + {#if current.migration_diagnostic}

{current.migration_diagnostic}

{/if} + {#if current.tracking?.status === "done"}

This handling was returned. Ticket {ticket.state} is unchanged.

{/if} + + {:else}

No current Orchestrator assignment.

{/if} + {#if error}{/if} + {#if savedNotice}

{savedNotice}

{/if} + {#if pending} +

Unresolved operation. New delegation is disabled until reconciled.

+ + + {/if} + {#if ticket.state === "closed" && ticket.action_eligibility.can_assign_orchestrator}

A delegation can be saved, but this closed case does not admit new work. Reopening the case is a separate decision and does not itself reissue handling.

{/if} + {#if ticket.action_eligibility.can_assign_orchestrator} +
Choose responsibility principal +
+ + {#if principalKind === "user"}{/if} + {#if principalKind === "worker"}{/if} +
+

Backend validates the selected principal. Responsibility does not choose or start a concrete Worker.

+
+ {/if} +
+ {#if ticket.action_eligibility.can_assign_orchestrator} + {#if !current || current.tracking?.status === "done"} + + {:else} +
Replace this delegation

A replacement starts a new assignment even for the same principal. The previous result and release history remain.

+ {/if} + {/if} + {#if ticket.action_eligibility.can_unassign_orchestrator}{/if} +
+ {#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"} +
+ +

Continues assignment {current.assignment_id}; not a new delegation. A stale assignment is rejected.

+ +
+ {/if} +
Past assignment and release history + {#if assignmentPageBusy}

Loading assignment history…

{/if} + {#each assignments?.items ?? [] as row (row.assignment_id)}

{row.assignment_id}{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}

{/each} + {#if assignments?.next_assignment_id}{/if} +
{ event.preventDefault(); void readHistory(historyId); }}>
+ {#if historyError}{/if} + {#if retained} +

{retained.assignment_id} · {retained.current ? "current" : "past"} · last handling {retained.tracking?.status ?? "unknown"}

+ {#if retained.tracking?.result}

Previous result: {retained.tracking.result}

{/if} + {#if retained.release}

{retained.release.action} by {retained.release.actor} · {formatDate(retained.release.at)}

{#if retained.release.replacement_assignment_id}{/if}{/if} + {/if} + {#if history}{#each history.items as entry (entry.event_id)}

{entry.tracking.status} · {formatDate(entry.at)}

{#if entry.tracking.waiting_reason}

Waiting reason: {entry.tracking.waiting_reason}

{/if}{#if entry.tracking.progress}

Progress: {entry.tracking.progress}

{/if}{#if entry.tracking.next_action}

Next action: {entry.tracking.next_action}

{/if}{#if entry.tracking.result}

Result: {entry.tracking.result}

{/if}
{/each}{#if history.next_event_id}{/if}{/if} +
+
+
+

Delivery and execution

+

Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}

+ {#if current}{/if} + {#if diagnosticError}{/if} + {#if diagnosticBusy}

Loading delivery diagnostics…

{/if} + {#if diagnostic} +

Assignment {diagnostic.assignment_id} · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}

+ {#if !diagnostic.input_origin_known}

Input origin unknown; not inferred from handling.

{/if} + {#each diagnostic.inputs as input (input.input_event_id)} +

Input {input.input_event_id}

Notification
{input.notification.replaceAll("_", " ")}
Runtime acceptance
{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}
+ {:else}

No delivery input records in this page.

{/each} +

Work start / admission evidence

+ {#each diagnostic.starts as start (start.operation_id)}

{start.operation_id}: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}

{:else}

No work start records observed. This is not inferred from Worker state.

{/each} + {#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}{/if} + {:else}

Delivery / Runtime acceptance / work start not observed.

{/if} +
+
+

Orchestrator Worker

+ {#if orchestrator?.worker}

{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: {orchestrator.worker.state} · {orchestrator.online ? "online" : "offline"}

Open Worker{:else}

Worker observation unavailable. This does not change handling status.

{/if} +
+ diff --git a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte new file mode 100644 index 000000000..c441e9014 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte @@ -0,0 +1,100 @@ + +
+

Classification tags

+

Classification only. Tag changes do not close or reopen this case.

+
{#each ticket.tags as tag (tag.id)}{tag.name}{:else}No tags{/each}
+ {#if loading}

Loading tag catalog…

{/if} + {#if error}{/if} + {#if saved}

Tag operation saved.

{/if} + {#if pending} +

Pending operation retained; new operations are disabled.

+ + + {/if} +
Edit classification +
{ event.preventDefault(); setTags(); }}> +
Multiple tags + {#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}{/each} +
+ +
+
+
Manage Workspace tags +
{ event.preventDefault(); mutateTag({ kind: "create", name: name.trim() }); }}> + + +
+ + {#if observed} +

{observed.id}

+ + + {/if} +
+
+ diff --git a/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte b/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte new file mode 100644 index 000000000..cf8e6dca1 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte @@ -0,0 +1,37 @@ + +
+

Related Worker — actual state

+ {#if loading}

Loading Worker observation…

{/if} + {#if error}{/if} + {#if worker}

{worker.runtime_id}/{worker.worker_id}: {worker.state} · availability {worker.availability}

Open Worker + {:else}

{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}

{/if} +

Worker state does not determine Ticket case state or self-reported handling.

+
diff --git a/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts new file mode 100644 index 000000000..de12d5186 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { + TicketAssignmentTrackingProjection, + TicketDetail, +} from "#lib/generated/ticket-api.ts"; +import { fixtureDetail } from "../home/dashboard.test-fixtures.ts"; +import TicketHandling from "./TicketHandling.svelte"; +import TicketTags from "./TicketTags.svelte"; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const assignment = ( + id = "delegation-1", + status: "waiting" | "done" = "waiting", +): TicketAssignmentTrackingProjection => ({ + workspace_id: "team", + ticket_id: "T-1", + assignment_id: id, + role: "orchestrator", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "user", + assigned_at: "2026-01-01T00:00:00Z", + current: true, + tracking: { + status, + waiting_reason: status === "waiting" ? "User answer needed" : null, + result: status === "done" ? "Options returned" : null, + }, + last_event_id: "event-1", +}); +function detail(row = assignment()): TicketDetail { + return { + ...fixtureDetail("T-1"), + action_eligibility: { + ...fixtureDetail("T-1").action_eligibility, + can_assign_orchestrator: true, + can_unassign_orchestrator: true, + }, + assignment_tracking: [row], + assignments: [{ + assignment_id: row.assignment_id, + role: row.role, + principal: row.principal, + assigned_by: row.assigned_by, + assigned_at: row.assigned_at, + }], + }; +} +function reads(path: string, row: TicketAssignmentTrackingProjection) { + if (path.includes("/assignment-tracking/assignments")) { + return Response.json({ items: [row], next_assignment_id: null }); + } + if (path.includes("/assignment-diagnostics")) { + return Response.json({ + assignment_id: row.assignment_id, + current: true, + actionable: true, + input_origin_known: true, + inputs: [{ + input_event_id: "input", + delivery_id: "delivery", + notification: "request_persisted", + acceptance_outcome_unknown: true, + }], + starts: [], + next_input_event_id: null, + next_start_operation_id: null, + }); + } + throw new Error(`Unexpected read ${path}`); +} +test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => { + const row = assignment(); + const sent: string[] = []; + let attempts = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/assignment-answer")) { + sent.push(String(init?.body)); + if (++attempts === 1) throw new Error("response lost"); + return Response.json({ + assignment_id: row.assignment_id, + input_event_id: "input-2", + event_index: 2, + }); + } + return reads(path, row); + }), + ); + const refresh = vi.fn(async () => {}); + const view = render(TicketHandling, { + workspaceId: "team", + ticket: detail(row), + orchestrator: null, + refresh, + }); + await fireEvent.input( + view.getByLabelText("Answer / continue this handling"), + { target: { value: "Use option B" } }, + ); + await fireEvent.click(view.getByText("Send answer to this assignment")); + await waitFor(() => expect(view.getByText(/Outcome unknown\./)).toBeTruthy()); + await view.rerender({ ticket: detail(assignment("delegation-2")) }); + expect( + (view.getByText("Send answer to this assignment") as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.click(view.getByText("Retry exact operation")); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + expect(sent).toHaveLength(2); + expect(sent[1]).toBe(sent[0]); + expect(JSON.parse(sent[1])).toEqual({ + assignment_id: "delegation-1", + expected_last_event_id: "event-1", + body: "Use option B", + operation_key: expect.any(String), + }); + expect(view.container.textContent).toContain("Handling status is unchanged"); +}); +test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => { + const row = assignment("delegation-1", "done"); + const sent: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + sent.push(JSON.parse(String(init.body))); + return Response.json({ + workspace_id: "team", + ticket_id: "T-1", + assignment: { + assignment_id: "delegation-2", + workspace_id: "team", + ticket_id: "T-1", + role: "orchestrator", + principal: row.principal, + assigned_at: row.assigned_at, + assigned_by: row.assigned_by, + }, + }); + } + return reads(String(input), row); + }), + ); + const view = render(TicketHandling, { + workspaceId: "team", + ticket: detail(row), + orchestrator: null, + refresh: vi.fn(async () => { + throw new Error("refresh unavailable"); + }), + }); + await fireEvent.click(view.getByText("Request again (new assignment)")); + await waitFor(() => + expect(view.getByText(/Assignment operation saved: delegation-2/)) + .toBeTruthy() + ); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" }); + expect(view.queryByText("Retry exact operation")).toBeNull(); + expect(view.container.textContent).toContain("Outcome unknown"); + expect(view.container.textContent).toContain( + "Outcome unknown — may already be accepted", + ); + expect(view.container.textContent).toContain( + "No work start records observed", + ); + expect(view.container.textContent).toContain("Self-reported handling: done"); +}); +test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => { + const tag = { + workspace_id: "team", + id: "tag-1", + name: "closed", + last_operation_key: "tag-create", + deleted: false, + }; + const requests: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + requests.push(String(init.body)); + return new Response("", { status: 409 }); + } + return Response.json([tag]); + }), + ); + const view = render(TicketTags, { + workspaceId: "team", + ticket: { ...detail(), tags: [] }, + refresh: vi.fn(async () => {}), + }); + await waitFor(() => expect(view.queryByText("Loading tag catalog…")).toBeNull()); + await fireEvent.click(view.getByLabelText("closed")); + await fireEvent.click(view.getByText("Save classification")); + await waitFor(() => expect(view.getByText(/Stale observation/)).toBeTruthy()); + await view.rerender({ ticket: { ...detail(), tags: [tag] } }); + await fireEvent.click(view.getByText("Retry exact tag operation")); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1]).toBe(requests[0]); + expect(JSON.parse(requests[1])).toMatchObject({ + expected_tag_ids: [], + tag_ids: ["tag-1"], + }); + expect((view.getByText("Save classification") as HTMLButtonElement).disabled) + .toBe(true); +}); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index da95d49f6..676e21bf2 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -1,7 +1,13 @@ import { + closureReasonLabel, + currentOrchestratorAssignment, + handlingLabel, + handlingSummary, nextTicketLaneVisibleCount, + readTicketFilters, TICKET_LANE_PAGE_SIZE, ticketLanes, + ticketQueryRequest, ticketWorkerLaunchHref, ticketWorkerMessage, } from "./ticket-panel.ts"; @@ -112,44 +118,107 @@ Deno.test("generic Ticket launch carries natural text without role or resource c } }); -Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => { - const source = await Deno.readTextFile( - new URL( - "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", - import.meta.url, +Deno.test("case, stable tags and principal filters survive every server-query page", () => { + const filters = readTicketFilters( + new URLSearchParams( + "q=options&case=open&tag=tag-1&tag=tag-2&principal=orchestrator", ), ); + for (const cursor of [null, "next-page"]) { + const request = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, ["open"], cursor)), + ); + assertEquals(request, { + query: "options", + states: ["open"], + tag_ids: ["tag-1", "tag-2"], + assigned_principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor, + }); + const handling = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, [], cursor, "done")), + ); + assertEquals(handling.handling_status, "done"); + assertEquals(handling.states, []); + } +}); - assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); - assertEquals(source.includes("ticket.relations.blockers.length > 0"), true); - assertEquals(source.includes("outcome.queued_tickets.join"), true); +Deno.test("current close-reason queries preserve known and custom reasons on every page without changing case intent", () => { + for ( + const [input, expected] of [["completed", "completed"], [ + "other&reason_text=Superseded%20plan", + { other: "Superseded plan" }, + ]] as const + ) { + const filters = readTicketFilters( + new URLSearchParams(`case=closed&reason=${input}`), + ); + for (const cursor of [null, "next-page"]) { + const request = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, ["closed"], cursor)), + ); + assertEquals(request.closure_reason, expected); + assertEquals(request.states, ["closed"]); + assertEquals(request.cursor, cursor); + } + } +}); + +Deno.test("handling summary uses only the current self-report and never Worker or case status", () => { + const row = { + assignment_id: "a", + current: true, + role: "orchestrator", + tracking: { status: "waiting", waiting_reason: "Awaiting user choice" }, + } as TicketDetail["assignment_tracking"][number]; + for (const state of ["open", "closed"] as const) { + for (const workerState of ["Running", "Stopped"]) { + const value = { + state, + workerState, + assignment_tracking: [{ + ...row, + assignment_id: "released", + current: false, + tracking: { status: "done" as const, result: "Old result" }, + }, row], + }; + const current = currentOrchestratorAssignment(value); + assertEquals(current?.assignment_id, "a"); + assertEquals(handlingLabel(current?.tracking?.status), "Waiting"); + assertEquals(handlingSummary(current), "Awaiting user choice"); + } + } assertEquals( - source.includes("resolve the listed blockers before Queue"), - false, + handlingSummary({ + ...row, + tracking: { status: "done", result: "Compared options" }, + }), + "Compared options", ); assertEquals( - source.includes("ticket.action_eligibility.can_assign_orchestrator"), - true, + handlingSummary({ + ...row, + tracking: null, + migration_diagnostic: "Unknown historical handling", + }), + "Unknown historical handling", ); + assertEquals(handlingLabel(null), "Unknown"); assertEquals( - source.includes('mutateAssignment("start-manual", "worker"'), - true, - ); - assertEquals( - source.includes('mutateAssignment("start-manual", "reviewer"'), - false, - ); - assertEquals(source.includes('kind: "workspace_agent"'), true); - assertEquals(source.includes('kind: "worker"'), true); - assertEquals(source.includes("ticket.assignee"), false); - assertEquals(source.includes('action: "set", targets'), true); - assertEquals(source.includes("updatedTicket.targets"), true); - assertEquals(source.includes("ticket.repository_key"), false); - assertEquals(source.includes("ticket.ref_selector"), false); - assertEquals( - source.includes("(repository.diagnostics ?? []).length > 0"), - false, + currentOrchestratorAssignment({ + assignment_tracking: [{ ...row, current: false }], + }), + null, ); + assertEquals(handlingSummary(null), "Not assigned"); + assertEquals(closureReasonLabel({ other: "Superseded" }), "Superseded"); }); Deno.test("Ticket authoring creates the target collection without singular authority", async () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 1a2a388d4..5e743bd62 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,7 +1,13 @@ import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts"; import type { + TicketAssignmentPrincipalSnapshot, + TicketAssignmentTrackingProjection, + TicketAssignmentTrackingStatus, + TicketClosureReason, TicketDetail, TicketListItemSummary, + TicketQueryItem, + TicketQueryRequest, } from "#lib/generated/ticket-api.ts"; export const TICKET_STATES = ["open", "closed"] as const; @@ -16,10 +22,14 @@ export const TICKET_LANE_PAGE_SIZE = 30; export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; -export type TicketCardSummary = Pick< - TicketListItemSummary, - "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" ->; +export type TicketCardSummary = + & Pick< + TicketListItemSummary, + "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" + > + & Partial< + Pick + >; export type TicketLane = { id: TicketLaneId; @@ -81,6 +91,148 @@ export function ticketWorkerLaunchHref( }/workers/new?${params.toString()}`; } +export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const; +export const ORCHESTRATOR_PRINCIPAL = { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", +} as const; + +export function currentOrchestratorAssignment( + ticket: Pick, +): TicketAssignmentTrackingProjection | null { + return ticket.assignment_tracking.find((row) => + row.current && row.role === "orchestrator" + ) ?? null; +} +export function handlingLabel( + status?: TicketAssignmentTrackingStatus | null, +): string { + return status === "waiting" + ? "Waiting" + : status === "inprogress" + ? "In progress" + : status === "done" + ? "Done" + : "Unknown"; +} +export function assignmentPrincipalLabel( + principal: TicketAssignmentPrincipalSnapshot, +): string { + if (principal.kind === "workspace_agent") { + return `Workspace agent: ${principal.agent_key}`; + } + if (principal.kind === "user") return `User account: ${principal.account_id}`; + return `Worker: ${principal.runtime_id}/${principal.worker_id}`; +} +export function handlingSummary( + row: TicketAssignmentTrackingProjection | null, +): string { + const tracking = row?.tracking; + if (!row) return "Not assigned"; + if (!tracking) return row.migration_diagnostic ?? "Handling unavailable"; + return tracking.status === "waiting" + ? tracking.waiting_reason ?? "Waiting reason not reported" + : tracking.status === "inprogress" + ? tracking.progress ?? "Progress not reported" + : tracking.result ?? "Result not reported"; +} +export function closureReasonLabel(reason: TicketClosureReason): string { + return typeof reason === "object" ? reason.other : ({ + completed: "Completed", + not_planned: "Not planned", + duplicate: "Duplicate", + unknown: "Unknown", + })[reason]; +} +export type TicketFilters = { + query: string; + caseState: "all" | TicketState; + tagIds: string[]; + principal: "all" | "orchestrator" | "user" | "worker"; + accountId: string; + runtimeId: string; + workerId: string; + closureReason: + | "all" + | "completed" + | "not_planned" + | "duplicate" + | "unknown" + | "other"; + closureText: string; +}; +export function readTicketFilters(search: URLSearchParams): TicketFilters { + const principal = search.get("principal"); + const state = search.get("case"); + const reason = search.get("reason"); + return { + query: search.get("q") ?? "", + caseState: state === "open" || state === "closed" ? state : "all", + tagIds: [...new Set(search.getAll("tag"))], + principal: principal === "orchestrator" || principal === "user" || + principal === "worker" + ? principal + : "all", + accountId: search.get("account") ?? "", + runtimeId: search.get("runtime") ?? "", + workerId: search.get("worker") ?? "", + closureReason: + ["completed", "not_planned", "duplicate", "unknown", "other"].includes( + reason ?? "", + ) + ? reason as TicketFilters["closureReason"] + : "all", + closureText: search.get("reason_text") ?? "", + }; +} +export function filterPrincipal( + filters: TicketFilters, +): TicketAssignmentPrincipalSnapshot | null { + if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL; + if (filters.principal === "user") { + if (!filters.accountId.trim()) { + throw new Error("Enter an account ID for the user principal filter"); + } + return { kind: "user", account_id: filters.accountId.trim() }; + } + if (filters.principal === "worker") { + if (!filters.runtimeId.trim() || !filters.workerId.trim()) { + throw new Error( + "Enter Runtime and Worker IDs for the Worker principal filter", + ); + } + return { + kind: "worker", + runtime_id: filters.runtimeId.trim(), + worker_id: filters.workerId.trim(), + }; + } + return null; +} +// Every page repeats the same server-side filters. Never search a loaded slice. +export function ticketQueryRequest( + filters: TicketFilters, + states: TicketState[], + cursor: string | null = null, + handlingStatus: TicketAssignmentTrackingStatus | null = null, +): TicketQueryRequest { + return { + query: filters.query.trim() || null, + states, + ...(filters.closureReason === "all" ? {} : { + closure_reason: filters.closureReason === "other" + ? { other: filters.closureText.trim() } + : filters.closureReason, + }), + tag_ids: filters.tagIds, + assigned_principal: filterPrincipal(filters), + handling_status: handlingStatus, + sort: "updated_desc", + limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"], + cursor, + }; +} + export function relationLabel(kind: string): string { return kind.replaceAll("_", " "); } diff --git a/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte new file mode 100644 index 000000000..99b3af00d --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte @@ -0,0 +1,85 @@ + + +Orchestrator · {data.workspaceId} +
+

Orchestrator

Ticket cases
+

Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}

+ {#if data.orchestrator.error}{/if} +
+ + + + +
+
+ {#each response.data?.items ?? [] as ticket (ticket.id)} + {@const row = currentOrchestratorAssignment(ticket)} + +
{ticket.resource_key}{ticket.title}
+
Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})Ticket case: {ticket.state}
+

{handlingSummary(row)}

+ {#if row?.tracking?.next_action}

Next action: {row.tracking.next_action}

{/if} + {#if ticket.current_closure}

Close reason: {closureReasonLabel(ticket.current_closure.reason)}

{/if} + Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"} + {#if row?.updated_at}{/if} + View Ticket, handling history and delivery evidence → +
+ {:else} + {#if !response.error}

No current assignments match these filters.

{/if} + {/each} + {#if response.error}{/if} + {#if busy}

Loading assignments…

{:else if response.data?.page.has_more && !response.error}{/if} +
+
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts new file mode 100644 index 000000000..fa1c7684f --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts @@ -0,0 +1,55 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "#lib/workspace/api/ticket-browser.ts"; +import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts"; +import { + HANDLING_STATES, + readTicketFilters, + ticketQueryRequest, +} from "#lib/workspace/tickets/ticket-panel.ts"; +import type { TicketAssignmentTrackingStatus } from "#lib/generated/ticket-api.ts"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params, url }) => { + const workspaceId = params.workspaceId; + const filters = { + ...readTicketFilters(url.searchParams), + principal: "orchestrator" as const, + tagIds: [], + closureReason: "all" as const, + closureText: "", + }; + const input = url.searchParams.get("handling"); + const handling = + HANDLING_STATES.includes(input as TicketAssignmentTrackingStatus) + ? input as TicketAssignmentTrackingStatus + : null; + const request = ticketQueryRequest( + filters, + filters.caseState === "all" ? [] : [filters.caseState], + null, + handling, + ); + const [tickets, orchestrator] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(workspaceId, "/tickets/query"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/orchestrator"), + undefined, + parseBrowserWorkspaceOrchestratorResponse, + ), + ]); + return { workspaceId, filters, handling, tickets, orchestrator }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 383828e4a..e8c961a31 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -11,27 +11,28 @@ import { TICKET_BROWSER_API_LOAD_POLICY, TICKET_BROWSER_API_MAX_RESPONSE_BYTES, - parseTicketListResponse, + parseTicketQueryResponse, parseTicketRecordRef, } from "#lib/workspace/api/ticket-browser.ts"; import type { NewTicket, QueryPage, - TicketListItemSummary as TicketSummary, + TicketQueryItem as TicketSummary, + TicketState, TicketTarget, TicketTargetAccess, } from "#lib/generated/ticket-api.ts"; import { ticketHref } from "#lib/workspace/resource-links.ts"; import { - ticketLanes, + ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel, type WorkspaceOrchestratorStatus, } from "#lib/workspace/tickets/ticket-panel.ts"; import type { PageData } from "./$types"; type LaneState = { - states: string[]; + states: TicketState[]; tickets: TicketSummary[]; - page: QueryPage; + page: QueryPage | null; loading: boolean; error: string | null; }; @@ -43,21 +44,19 @@ }; let { data }: { data: PageData } = $props(); - // svelte-ignore state_referenced_locally - let laneState = $state>( - Object.fromEntries( - Object.entries(data.ticketLanes).map(([laneId, lane]) => [ - laneId, - { - states: [...lane.states], - tickets: lane.response.items, - page: lane.response.page, - loading: false, - error: null, - }, - ]), - ), - ); + function initialLanes(): Record { + return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, { + states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null, + loading: false, error: result.error, + }]] : [])); + } + let laneState = $state>(untrack(initialLanes)); + let principal = $state(untrack(() => data.filters.principal)); + let closureChoice = $state(untrack(() => data.filters.closureReason)); + let filtersBusy = $state(false); + $effect(() => { const currentData = data; untrack(() => { + laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator; + }); }); let orchestrator = $state>( untrack(() => data.orchestrator), ); @@ -81,10 +80,16 @@ } return true; }); - const tickets = $derived( - Object.values(laneState).flatMap((lane) => lane.tickets), - ); - const lanes = $derived(ticketLanes(tickets)); + const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets }))); + + async function applyFilters(event: SubmitEvent) { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + const search = new URLSearchParams(); + for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value); + filtersBusy = true; + try { await goto(`?${search}`); } finally { filtersBusy = false; } + } function mergeTickets( current: TicketSummary[], @@ -97,22 +102,17 @@ async function loadMore(laneId: string): Promise { const lane = laneState[laneId]; - if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) { + if (!lane || lane.loading || (lane.page && !lane.page.has_more)) { return; } lane.loading = true; lane.error = null; try { - const search = new URLSearchParams({ - limit: "30", - states: lane.states.join(","), - cursor: lane.page.next_cursor, - }); const result = await loadJson( fetch, - `/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`, - undefined, - parseTicketListResponse, + workspaceApiPath(data.workspaceId, "/tickets/query"), + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) }, + parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY, ); if (!result.data) { @@ -127,13 +127,6 @@ } } - function handleLaneScroll(event: Event, laneId: string): void { - const container = event.currentTarget as HTMLElement; - const remaining = - container.scrollHeight - container.scrollTop - container.clientHeight; - if (remaining <= 96) void loadMore(laneId); - } - function addCreateTarget(): void { createTargets.push({ repository_key: "", @@ -227,9 +220,9 @@
Orchestrator - {orchestrator.data?.online ? "Online" : "Offline"} + {orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}
- {#if !orchestrator.data?.online} + {#if orchestrator.data && !orchestrator.data.online} + Orchestrator handling + + +
{#each lanes as lane (lane.id)} {@const pagination = laneState[lane.id]}
@@ -310,14 +316,14 @@

{lane.label}

- {lane.tickets.length} + {lane.tickets.length} loaded - {:else if pagination && !pagination.page.has_more && lane.tickets.length > 0} + {:else if pagination?.page?.has_more} + + {:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}

End of lane

{/if} @@ -347,3 +362,22 @@ {/each}
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index 9721b4e1d..0078b50b1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -2,56 +2,52 @@ import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/wo import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { - parseTicketListResponse, + parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY, } from "#lib/workspace/api/ticket-browser.ts"; -import type { TicketListResponse } from "#lib/generated/ticket-api.ts"; -import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts"; +import { parseTags } from "#lib/workspace/api/ticket-operations.ts"; +import { + readTicketFilters, + ticketQueryRequest, +} from "#lib/workspace/tickets/ticket-panel.ts"; +import type { TicketState } from "#lib/generated/ticket-api.ts"; import type { PageLoad } from "./$types"; -const LANE_STATES = { open: ["open"], closed: ["closed"] } as const; - -export type TicketLaneId = keyof typeof LANE_STATES; - -export type TicketLanePage = { - states: readonly string[]; - response: TicketListResponse; -}; - -async function loadLane( - fetchFn: typeof fetch, - workspaceId: string, - laneId: TicketLaneId, -): Promise { - const states = LANE_STATES[laneId]; - const search = new URLSearchParams({ - limit: "30", - states: states.join(","), - }); - const result = await loadJson( - fetchFn, - `/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`, - undefined, - parseTicketListResponse, - TICKET_BROWSER_API_LOAD_POLICY, - ); - if (!result.data) { - throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`); - } - return { states: [...states], response: result.data }; -} - -export const load: PageLoad = async ({ fetch, params }) => { +export const load: PageLoad = async ({ fetch, params, url }) => { const workspaceId = params.workspaceId; - const [ - open, - closed, - orchestrator, - repositories, - ] = await Promise.all([ - loadLane(fetch, workspaceId, "open"), - loadLane(fetch, workspaceId, "closed"), - loadJson( + const filters = readTicketFilters(url.searchParams); + const loadLane = async (state: TicketState) => { + if (filters.caseState !== "all" && filters.caseState !== state) return null; + try { + return await loadJson( + fetch, + workspaceApiPath(workspaceId, "/tickets/query"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ticketQueryRequest(filters, [state])), + }, + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + } catch (error) { + return { + data: null, + error: error instanceof Error ? error.message : String(error), + }; + } + }; + const [open, closed, tags, orchestrator, repositories] = await Promise.all([ + loadLane("open"), + loadLane("closed"), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/ticket-tags"), + undefined, + parseTags, + TICKET_BROWSER_API_LOAD_POLICY, + ), + loadJson( fetch, workspaceApiPath(workspaceId, "/orchestrator"), undefined, @@ -59,13 +55,11 @@ export const load: PageLoad = async ({ fetch, params }) => { ), loadWorkspaceRepositoryList(fetch, workspaceId), ]); - return { workspaceId, - ticketLanes: { - open, - closed, - }, + filters, + ticketLanes: { open, closed }, + tags, orchestrator, repositories, }; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index b2536cc0c..cd2b07a74 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -1,5 +1,9 @@