From 2a55bef036546d810bf0b7ac10c6c24a294dce8d Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 12 Oct 2026 02:54:45 +0900 Subject: [PATCH] feat(ticket): persist Orchestrator assignment tracking and audit --- crates/server-api/src/lib.rs | 125 ++ .../src/repository_openapi_typescript.rs | 21 + crates/ticket/src/assignment_schema.sql | 105 ++ crates/ticket/src/assignment_tracking.rs | 900 ++++++++++ .../ticket/src/assignment_tracking_schema.sql | 128 ++ .../ticket/src/assignment_tracking_tests.rs | 730 ++++++++ crates/ticket/src/lib.rs | 2 + crates/ticket/src/sqlite_schema.rs | 92 +- .../feature/builtin/resource_projection.rs | 74 +- crates/worker/src/feature/builtin/ticket.rs | 96 +- .../builtin/ticket/assignment_tracking.rs | 466 ++++++ .../src/feature/builtin/ticket/tags/tests.rs | 4 +- .../src/assignment_tracking.rs | 173 ++ .../src/assignment_tracking_tests.rs | 1486 +++++++++++++++++ crates/workspace-server/src/authority.rs | 19 + crates/workspace-server/src/latest_schema.sql | 114 +- crates/workspace-server/src/server.rs | 387 ++++- .../src/server/tests/drive_api_tests.rs | 2 + .../src/server/tests/subjektiv_jobs_tests.rs | 1 + .../server/tests/worker_operations_tests.rs | 4 + .../src/server_assignment_tracking_tests.rs | 1344 +++++++++++++++ crates/workspace-server/src/store.rs | 826 ++++++--- .../src/ticket_tag_upgrade_tests.rs | 20 +- .../src/worker_source/runtime_request.rs | 4 + .../src/workspace_deletion.rs | 89 +- .../development/ticket-assignment-tracking.md | 47 + ...26-10-11-subworker-advisory-surface-gap.md | 15 + openapi/server-api.json | 710 +++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 26 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/api/ticket-browser.test.ts | 47 + .../src/lib/workspace/api/ticket-browser.ts | 133 ++ 35 files changed, 7706 insertions(+), 492 deletions(-) create mode 100644 crates/ticket/src/assignment_schema.sql create mode 100644 crates/ticket/src/assignment_tracking.rs create mode 100644 crates/ticket/src/assignment_tracking_schema.sql create mode 100644 crates/ticket/src/assignment_tracking_tests.rs create mode 100644 crates/worker/src/feature/builtin/ticket/assignment_tracking.rs create mode 100644 crates/workspace-server/src/assignment_tracking.rs create mode 100644 crates/workspace-server/src/assignment_tracking_tests.rs create mode 100644 crates/workspace-server/src/server_assignment_tracking_tests.rs create mode 100644 docs/development/ticket-assignment-tracking.md create mode 100644 docs/report/2026-10-11-subworker-advisory-surface-gap.md diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b34b84458..3acc5956a 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -347,6 +347,12 @@ impl_openapi_schema!( TicketDependencyCheckResponse, TicketDetail, TicketCaseUpdateRequest, + TicketAssignmentTrackingUpdateRequest, + TicketAssignmentTrackingRecord, + TicketAssignmentTrackingList, + TicketAssignmentTrackingQuery, + TicketAssignmentTrackingHistoryQuery, + TicketAssignmentTrackingHistoryPage, TicketSummary, TicketDoctorResponse, TicketIntakeSummaryRequest, @@ -3190,6 +3196,30 @@ pub trait ServerApi { #[path] ticket_id: String, #[body] request: TicketCaseUpdateRequest, ) -> 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, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentTrackingQuery, + ) -> Result; + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_tracking_update( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentTrackingUpdateRequest, + ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_tracking_history( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentTrackingHistoryQuery, + ) -> Result; #[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_relation_view( &self, @@ -3254,6 +3284,7 @@ pub trait ServerApi { #[put("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_set( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[path] role: String, @@ -3263,6 +3294,7 @@ pub trait ServerApi { #[delete("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_clear( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[path] role: String, @@ -3571,6 +3603,9 @@ pub struct ServerWorkerSource { pub struct ServerRuntimeSource { pub runtime_id: String, pub worker_id: Option, + /// Binding whose public key authenticated this request. Retain the verified + /// identity through mutation CAS/replay checks; a later lookup is not proof. + pub authentication_binding_id: String, } #[derive(Clone)] @@ -7631,6 +7666,11 @@ pub struct TicketQueryItem { #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub unresolved_review_count: usize, + #[cfg_attr( + feature = "typescript", + ts(type = "Array") + )] + pub assignment_tracking: Vec, pub evidence: TicketEvidenceSummary, pub merge_requests: Vec, pub merge_request: Option, @@ -7700,6 +7740,11 @@ pub struct TicketDetail { pub linked_objectives: Vec, pub implementation_reports: Vec, pub assignments: Vec, + #[cfg_attr( + feature = "typescript", + ts(type = "Array") + )] + pub assignment_tracking: Vec, pub current_worker: Option, pub assignment_diagnostics: Vec, pub action_eligibility: TicketActionEligibility, @@ -7777,6 +7822,42 @@ pub struct TicketCaseUpdateRequest { #[serde(default)] pub references: Vec, } +transparent_ticket_dto!( + TicketAssignmentTrackingUpdateRequest, + ticket::TicketAssignmentTrackingUpdate +); +transparent_ticket_dto!( + TicketAssignmentTrackingRecord, + ticket::TicketAssignmentTrackingProjection +); +transparent_ticket_dto!( + TicketAssignmentTrackingList, + Vec +); + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingQuery { + /// Omit for the current delegation; provide to inspect a retained assignment. + pub assignment_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryQuery { + pub assignment_id: String, + pub after_event_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryPage { + pub items: Vec, + pub next_event_id: Option, +} + transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); transparent_ticket_dto!( WorkspaceTicketTagMutationRequest, @@ -13022,6 +13103,50 @@ mod tests { } } + #[test] + fn ticket_assignment_tracking_wire_has_explicit_event_cas_without_actor_claims() { + for status in ["waiting", "inprogress", "done"] { + let input = serde_json::json!({"assignment_id":"delegation", "expected_last_event_id":"observed-event", "operation_key":"handling", "tracking":{"status":status, "waiting_reason":null, "progress":"Investigated", "next_action":null, "result":null}}); + let request: TicketAssignmentTrackingUpdateRequest = + serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), input); + for field in [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking", + ] { + let mut missing = input.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing) + .is_err(), + "{field}" + ); + } + for field in [ + "author", + "role", + "source_context", + "runtime_id", + "worker_id", + ] { + let mut invalid = input.clone(); + invalid[field] = serde_json::json!("orchestrator"); + assert!( + serde_json::from_value::(invalid) + .is_err(), + "{field}" + ); + } + let mut invalid = input.clone(); + invalid["tracking"]["status"] = serde_json::json!("running"); + assert!( + serde_json::from_value::(invalid).is_err() + ); + } + } + #[test] fn ticket_query_accepts_only_case_states_and_current_attention_filters() { let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({ diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 1e96670c3..fe533c862 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -500,6 +500,27 @@ fn validate_ticket_browser_operations( request: Some("TicketCaseUpdateRequest"), successes: &[("200", "TicketSummary")], }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", + method: "get", + operation_id: "ticket_assignment_tracking_get", + request: None, + successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", + method: "post", + operation_id: "ticket_assignment_tracking_update", + request: Some("TicketAssignmentTrackingUpdate"), + successes: &[("200", "TicketAssignmentTrackingProjection")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", + method: "get", + operation_id: "ticket_assignment_tracking_history", + request: None, + successes: &[("200", "TicketAssignmentTrackingHistoryPage")], + }, OperationContract { path: "/api/w/{workspace_id}/tickets/{id}/ready", method: "post", diff --git a/crates/ticket/src/assignment_schema.sql b/crates/ticket/src/assignment_schema.sql new file mode 100644 index 000000000..8dadb7846 --- /dev/null +++ b/crates/ticket/src/assignment_schema.sql @@ -0,0 +1,105 @@ +-- Ticket owns assignment identities, current pointers, audit and operation receipts. +CREATE TABLE IF NOT EXISTS ticket_assignment_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + claim_state TEXT NOT NULL DEFAULT 'committed' + CHECK(claim_state IN ('pending', 'failed', 'committed')), + failure_reason TEXT, + binding_recovery_json TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK(claim_state = 'committed' OR assignment_id IS NULL), + CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) + ); +CREATE TABLE IF NOT EXISTS ticket_current_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + assignment_id TEXT NOT NULL, + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) + ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE IF NOT EXISTS ticket_worker_assignment_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + operation_id TEXT, + reason TEXT, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE IF NOT EXISTS ticket_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_principal + ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); +CREATE INDEX IF NOT EXISTS ticket_current_principal_idx + ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); +CREATE UNIQUE INDEX IF NOT EXISTS ticket_current_singleton_role_idx + ON ticket_current_worker_assignments(workspace_id, ticket_id, role) + WHERE role IN ('orchestrator', 'worker'); diff --git a/crates/ticket/src/assignment_tracking.rs b/crates/ticket/src/assignment_tracking.rs new file mode 100644 index 000000000..9aada331a --- /dev/null +++ b/crates/ticket/src/assignment_tracking.rs @@ -0,0 +1,900 @@ +//! Ticket-owned assignment tracking. Runtime execution, grants and notification +//! delivery are intentionally not tracking status. The host resolves the current +//! workspace-agent Worker and authenticates it before constructing source context. +use super::*; + +/// Used by the Workspace host to compose its stricter principal constraints. +/// Historical physical names remain to preserve immutable IDs and receipts. +pub const TICKET_ASSIGNMENT_SCHEMA_SQL: &str = include_str!("assignment_schema.sql"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum TicketAssignmentTrackingStatus { + Waiting, + Inprogress, + Done, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTracking { + pub status: TicketAssignmentTrackingStatus, + pub waiting_reason: Option, + pub progress: Option, + pub next_action: Option, + pub result: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingUpdate { + pub assignment_id: String, + pub expected_last_event_id: String, + pub operation_key: String, + pub tracking: TicketAssignmentTracking, +} + +/// Backend-owned authenticated context, never decoded from model input. Agent +/// principal remains stable across Worker exchange; exact replay binds to the +/// authenticated concrete sender and its binding, not merely a claimed role. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TicketAssignmentTrackingSourceContext { + pub actor: String, + pub runtime_id: String, + pub worker_id: String, + pub agent_key: String, + pub authentication_binding_id: String, +} + +/// Enrichment is separate from the established assignment record. `None` +/// tracking means a retained historical relationship with insufficient delegation +/// evidence, not waiting/done. Removal/replacement does not alter last tracking. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingProjection { + pub workspace_id: String, + pub ticket_id: String, + pub assignment_id: String, + pub role: String, + pub principal: TicketAssignmentPrincipalSnapshot, + pub assigned_by: String, + pub assigned_at: String, + pub release: Option, + pub current: bool, + pub tracking: Option, + /// Tracking event, or the existing immutable Assign audit observation for a + /// retained current relationship with no tracking yet. This baseline does + /// not imply a handling status, delegation intent, or tracking timestamp. + pub last_event_id: Option, + pub updated_at: Option, + pub migration_diagnostic: Option, +} + +/// Immutable principal as stored at Assign, not live Worker availability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TicketAssignmentPrincipalSnapshot { + User { + account_id: String, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + WorkspaceAgent { + agent_key: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentReleaseAction { + Unassigned, + Replaced, +} + +/// Release is an independent immutable assignment audit event. Last tracking +/// remains unchanged, including unknown legacy tracking; it is never made done. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentRelease { + pub action: TicketAssignmentReleaseAction, + pub event_id: String, + pub operation_key: Option, + pub actor: String, + pub at: String, + pub reason: Option, + pub replacement_assignment_id: Option, + pub last_tracking: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingHistoryEntry { + pub event_id: String, + pub operation_key: String, + pub tracking: TicketAssignmentTracking, + pub source_context: serde_json::Value, + pub at: String, +} + +/// Bounded append-order page. Cursor is an immutable event identity, not a +/// lexical event-ID comparison or a transferable offset into another assignment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingHistoryPage { + pub items: Vec, + pub next_event_id: Option, +} + +fn valid_identity(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) +} + +fn validate_update( + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result<()> { + if [ + &update.assignment_id, + &update.expected_last_event_id, + &update.operation_key, + &source.actor, + &source.runtime_id, + &source.worker_id, + &source.authentication_binding_id, + ] + .into_iter() + .any(|value| !valid_identity(value)) + || source.agent_key != "workspace-orchestrator" + { + return Err(TicketError::Conflict( + "tracking requires bounded identities and authenticated workspace-orchestrator source" + .into(), + )); + } + for text in [ + &update.tracking.waiting_reason, + &update.tracking.progress, + &update.tracking.next_action, + &update.tracking.result, + ] + .into_iter() + .flatten() + { + if text.len() > 16 * 1024 || text.contains('\0') { + return Err(TicketError::Conflict( + "tracking fields must be at most 16384 bytes without NUL".into(), + )); + } + } + Ok(()) +} + +/// Reads responsibility independently of case state and operational work release. +pub fn sqlite_ticket_assignment_tracking( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + let row = conn.query_row("SELECT a.tracking_json, a.last_tracking_event_id, a.tracking_updated_at, a.tracking_migration_diagnostic, + EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id), + a.role, a.principal_kind, a.principal_id, a.runtime_id, a.worker_id, a.assigned_by, a.assigned_at + FROM ticket_worker_assignments a WHERE a.workspace_id=?1 AND a.ticket_id=?2 AND a.assignment_id=?3 AND a.role='orchestrator'", + params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,Option>(0)?,r.get::<_,Option>(1)?,r.get::<_,Option>(2)?,r.get::<_,Option>(3)?,r.get::<_,bool>(4)?, r.get::<_,String>(5)?, r.get::<_,String>(6)?,r.get::<_,Option>(7)?,r.get::<_,Option>(8)?,r.get::<_,Option>(9)?,r.get::<_,String>(10)?,r.get::<_,String>(11)?))) + .optional().map_err(sqlite_err)?; + let Some(( + json, + last_event_id, + updated_at, + migration_diagnostic, + current, + role, + kind, + principal_id, + runtime_id, + worker_id, + assigned_by, + assigned_at, + )) = row + else { + return Ok(None); + }; + let tracking: Option = json + .map(|value| serde_json::from_str(&value).map_err(sqlite_err)) + .transpose()?; + // Legacy migration may retain current responsibility without evidence of + // handling status. Its real Assign audit is still an immutable observation + // against which the first explicit self-report can CAS, even after close. + // Never manufacture a tracking event/status or repair a missing audit. Once + // tracking exists, only its event identity is a valid observation. + let last_event_id = if current && tracking.is_none() && last_event_id.is_none() { + conn.query_row( + "SELECT e.event_id FROM ticket_worker_assignment_events e + WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND e.assignment_id=?3 + AND e.role='orchestrator' AND e.action IN ('assigned','reassigned') + AND NOT EXISTS (SELECT 1 FROM typed_ticket_assignment_tracking_history h + WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3) + ORDER BY e.rowid LIMIT 1", + params![workspace_id, ticket_id, assignment_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(sqlite_err)? + } else { + last_event_id + }; + let missing = + || TicketError::Conflict("stored assignment principal identity is incomplete".into()); + let principal = match kind.as_str() { + "workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent { + agent_key: principal_id.ok_or_else(missing)?, + }, + "user" => TicketAssignmentPrincipalSnapshot::User { + account_id: principal_id.ok_or_else(missing)?, + }, + "worker" => TicketAssignmentPrincipalSnapshot::Worker { + runtime_id: runtime_id.ok_or_else(missing)?, + worker_id: worker_id.ok_or_else(missing)?, + }, + _ => return Err(missing()), + }; + let release = conn.query_row("SELECT action,event_id,operation_id,actor,created_at,reason,assignment_id FROM ticket_worker_assignment_events + WHERE workspace_id=?1 AND ticket_id=?2 AND ((action='unassigned' AND (assignment_id=?3 OR previous_assignment_id=?3)) OR (action='reassigned' AND previous_assignment_id=?3)) ORDER BY rowid DESC LIMIT 1", + params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,Option>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Option>(5)?,r.get::<_,Option>(6)?))).optional().map_err(sqlite_err)? + .map(|(action,event_id,operation_key,actor,at,reason,replacement)| TicketAssignmentRelease { + action: if action=="unassigned" {TicketAssignmentReleaseAction::Unassigned} else {TicketAssignmentReleaseAction::Replaced}, + event_id,operation_key,actor,at,reason,replacement_assignment_id: if action=="reassigned" {replacement} else {None}, last_tracking: tracking.clone(), + }); + Ok(Some(TicketAssignmentTrackingProjection { + workspace_id: workspace_id.into(), + ticket_id: ticket_id.into(), + assignment_id: assignment_id.into(), + role, + principal, + assigned_by, + assigned_at, + release, + current, + tracking, + last_event_id, + updated_at, + migration_diagnostic, + })) +} + +pub fn sqlite_current_ticket_assignment_tracking( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + let ids = conn.prepare("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' ORDER BY assignment_id") + .map_err(sqlite_err)?.query_map(params![workspace_id,ticket_id],|r|r.get::<_,String>(0)).map_err(sqlite_err)? + .collect::,_>>().map_err(sqlite_err)?; + ids.into_iter() + .map(|id| { + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &id)? + .ok_or_else(|| TicketError::Conflict("current assignment row is missing".into())) + }) + .collect() +} + +pub fn sqlite_ticket_assignment_tracking_history( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + let mut stmt=conn.prepare("SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h + WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 ORDER BY h.rowid").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![workspace_id, ticket_id, assignment_id], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + }) + .map_err(sqlite_err)?; + rows.map(|r| { + let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?; + Ok(TicketAssignmentTrackingHistoryEntry { + event_id, + operation_key, + tracking: serde_json::from_str(&json).map_err(sqlite_err)?, + source_context: serde_json::from_str(&source).map_err(sqlite_err)?, + at, + }) + }) + .collect() +} + +/// DB-bounded history pagination. Immutable append-only rows provide a stable +/// cursor position; the scope index also orders its implicit rowids. No caller +/// needs to load, deserialize or truncate the lifetime assignment history. +pub fn sqlite_ticket_assignment_tracking_history_page( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(TicketError::Conflict( + "tracking history limit must be 1..100".into(), + )); + } + let exists:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator')",params![workspace_id,ticket_id,assignment_id],|r|r.get(0)).map_err(sqlite_err)?; + if !exists { + return Err(TicketError::Conflict( + "tracking assignment is not in this Workspace/Ticket".into(), + )); + } + let after_rowid:Option=after_event_id.map(|event| conn.query_row("SELECT rowid FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND event_id=?4",params![workspace_id,ticket_id,assignment_id,event],|r|r.get::<_,i64>(0)).optional().map_err(sqlite_err)?.ok_or_else(||TicketError::Conflict("tracking history cursor is not in this exact Workspace/Ticket/assignment".into()))).transpose()?; + let after_clause = if after_rowid.is_some() { + "AND h.rowid>?4" + } else { + "AND ?4 IS NULL" + }; + let sql = format!( + "SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 {after_clause} ORDER BY h.rowid LIMIT ?5" + ); + let mut stmt = conn.prepare(&sql).map_err(sqlite_err)?; + let rows = stmt + .query_map( + params![ + workspace_id, + ticket_id, + assignment_id, + after_rowid, + (limit + 1) as i64 + ], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + }, + ) + .map_err(sqlite_err)?; + let mut items = rows + .map(|r| { + let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?; + Ok(TicketAssignmentTrackingHistoryEntry { + event_id, + operation_key, + tracking: serde_json::from_str(&json).map_err(sqlite_err)?, + source_context: serde_json::from_str(&source).map_err(sqlite_err)?, + at, + }) + }) + .collect::>>()?; + let has_more = items.len() > limit; + items.truncate(limit); + let next_event_id = if has_more { + items.last().map(|entry| entry.event_id.clone()) + } else { + None + }; + Ok(TicketAssignmentTrackingHistoryPage { + items, + next_event_id, + }) +} + +/// Host owns BEGIN IMMEDIATE/commit. Receipt lookup precedes current/CAS checks; +/// exact replay returns the immutable operation result even after reassignment. +/// Case closed and work-release records are deliberately absent from this guard. +pub fn sqlite_update_ticket_assignment_tracking_in_transaction( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + if conn.is_autocommit() { + return Err(TicketError::Conflict( + "tracking update requires a host write transaction".into(), + )); + } + validate_update(update, source)?; + let input_json = serde_json::to_string(&(ticket_id, update)).map_err(sqlite_err)?; + let source_json = serde_json::to_string(source).map_err(sqlite_err)?; + let receipt=conn.query_row("SELECT input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",params![workspace_id,update.operation_key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?))).optional().map_err(sqlite_err)?; + if let Some((input, context, result)) = receipt { + if input != input_json || context != source_json { + return Err(TicketError::Conflict( + "tracking operation key already used for different input or authenticated source" + .into(), + )); + } + return serde_json::from_str(&result).map_err(sqlite_err); + } + let current = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)? + .ok_or_else(|| { + TicketError::Conflict( + "tracking assignment not found in this Workspace/Ticket".into(), + ) + })?; + if !current.current + || current.last_event_id.as_deref() != Some(update.expected_last_event_id.as_str()) + { + return Err(TicketError::Conflict( + "tracking assignment is removed/replaced or last event changed".into(), + )); + } + let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?; + if !principal_matches { + return Err(TicketError::Conflict( + "authenticated agent does not own this assignment".into(), + )); + } + let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?); + let at = Utc::now().to_rfc3339(); + let tracking_json = serde_json::to_string(&update.tracking).map_err(sqlite_err)?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,tracking_json,event_id,at]).map_err(sqlite_err)?; + conn.execute( + "INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace_id, + ticket_id, + update.assignment_id, + event_id, + update.operation_key, + tracking_json, + source_json, + at + ], + ) + .map_err(sqlite_err)?; + let result = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)? + .ok_or_else(|| TicketError::Conflict("tracking assignment disappeared".into()))?; + conn.execute( + "INSERT INTO typed_ticket_assignment_tracking_operations VALUES (?1,?2,?3,?4,?5,?6)", + params![ + workspace_id, + update.operation_key, + ticket_id, + input_json, + source_json, + serde_json::to_string(&result).map_err(sqlite_err)? + ], + ) + .map_err(sqlite_err)?; + Ok(result) +} + +pub(crate) fn install_assignment_core(conn: &Connection) -> Result<()> { + // Standalone Ticket stores do not own Workspace records. The Workspace host + // composes the unmodified SQL and adds its principal/resource constraints. + let sql = if conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='workspaces')", + [], + |r| r.get::<_, bool>(0), + ) + .map_err(sqlite_err)? + { + TICKET_ASSIGNMENT_SCHEMA_SQL.to_owned() + } else { + TICKET_ASSIGNMENT_SCHEMA_SQL.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "").replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n") + }; + conn.execute_batch(&sql).map_err(sqlite_err) +} + +pub(crate) fn migrate_assignment_tracking(conn: &Connection) -> Result<()> { + install_assignment_core(conn)?; + conn.execute_batch(include_str!("assignment_tracking_schema.sql")) + .map_err(sqlite_err)?; + reconcile_legacy_tracking(conn) +} + +fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> { + let rows=conn.prepare("SELECT a.workspace_id,a.ticket_id,a.assignment_id,s.snapshot_json, + EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id) + FROM ticket_worker_assignments a LEFT JOIN typed_ticket_legacy_workflow_snapshot s ON s.workspace_id=a.workspace_id AND s.ticket_id=a.ticket_id WHERE a.role='orchestrator'").map_err(sqlite_err)? + .query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,Option>(3)?,r.get::<_,bool>(4)?))).map_err(sqlite_err)? + .collect::,_>>().map_err(sqlite_err)?; + for (workspace, ticket, assignment, snapshot, current) in rows { + let original = snapshot + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(sqlite_err)?; + // Read the latest routing intention, not just the latest matching + // fence: a newer mismatched Queue must not revive an older delegation. + let queue=conn.query_row("SELECT e.event_index,e.at,(e.state_field='routing' AND e.reason='queued'),f.value FROM typed_ticket_events e LEFT JOIN typed_ticket_event_attributes f ON f.workspace_id=e.workspace_id AND f.ticket_id=e.ticket_id AND f.event_index=e.event_index AND f.key='orchestrator_assignment_id' + WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued')) ORDER BY e.event_index DESC LIMIT 1",params![workspace,ticket],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,Option>(1)?,r.get::<_,Option>(2)?.unwrap_or(false),r.get::<_,Option>(3)?))).optional().map_err(sqlite_err)?; + let original_active = original + .as_ref() + .is_some_and(|s| matches!(s.workflow_state.as_str(), "queued" | "inprogress")); + if let Some((index, at, typed_queue, _)) = queue.filter(|(_, _, typed_queue, fence)| { + current + && (original_active || *typed_queue) + && fence.as_deref() == Some(assignment.as_str()) + }) { + // A case judgment is earlier only when its immutable operation key + // identifies a case event before Queue. Missing correspondence is + // ambiguous; neither timestamp order nor Worker state repairs it. + let later_case:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.request_fingerprint!='migration-v10' + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes operation JOIN typed_ticket_events e ON e.workspace_id=operation.workspace_id AND e.ticket_id=operation.ticket_id AND e.event_index=operation.event_index + WHERE operation.workspace_id=h.workspace_id AND operation.ticket_id=h.ticket_id AND operation.key='operation_key' AND operation.value=h.operation_key AND e.state_field='case' AND e.event_index?3 AND (state_field='case' OR to_state IN ('planning','ready','done','closed') OR (to_state='open' AND COALESCE(state_field,'')!='routing')))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?; + if !later_case && !superseded { + let status = if !typed_queue + && original + .as_ref() + .is_some_and(|s| s.workflow_state == "inprogress") + { + TicketAssignmentTrackingStatus::Inprogress + } else { + TicketAssignmentTrackingStatus::Waiting + }; + let tracking = TicketAssignmentTracking { + status, + waiting_reason: Some( + "Migrated fenced legacy delegation; Orchestrator confirmation pending" + .into(), + ), + progress: None, + next_action: None, + result: None, + }; + let event_id = format!("migration-tracking:{assignment}"); + let at = at.unwrap_or_else(|| Utc::now().to_rfc3339()); + let json = serde_json::to_string(&tracking).map_err(sqlite_err)?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5,tracking_migration_diagnostic=?6 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment,json,event_id,at,format!("Retained original workflow snapshot and latest Queue event {index} (typed routing={typed_queue}) fenced to this current assignment")]).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?4,?5,'{\"kind\":\"migration\"}',?6)",params![workspace,ticket,assignment,event_id,json,at]).map_err(sqlite_err)?; + continue; + } + } + conn.execute("UPDATE ticket_worker_assignments SET tracking_migration_diagnostic='Historical assignment retained without tracking: absent/ambiguous or superseded original workflow and exact Queue fence; no delegation or done inferred' WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment]).map_err(sqlite_err)?; + } + Ok(()) +} + +pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> { + // Object names alone would accept same-named no-op triggers after drift. + let canonical = Connection::open_in_memory().map_err(sqlite_err)?; + canonical + .execute_batch(TICKET_ASSIGNMENT_SCHEMA_SQL) + .map_err(sqlite_err)?; + canonical + .execute_batch(include_str!("assignment_tracking_schema.sql")) + .map_err(sqlite_err)?; + let normalize = |sql: String| { + // SQL keyword/format differences are harmless; literal case and spaces + // are not (for example changing the principal or role in a guard). + let mut result = String::new(); + let mut quoted = false; + let mut chars = sql.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\'' { + result.push(ch); + if quoted && chars.peek() == Some(&'\'') { + result.push(chars.next().unwrap()); + } else { + quoted = !quoted; + } + } else if quoted { + result.push(ch); + } else if !ch.is_whitespace() { + result.push(ch.to_ascii_lowercase()); + } + } + result.replace("ifnotexists", "") + }; + for name in [ + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", + "typed_ticket_assignment_tracking_initialize", + "typed_ticket_assignment_tracking_assigned", + "typed_ticket_assignment_tracking_history_no_update", + "typed_ticket_assignment_tracking_history_no_delete", + "typed_ticket_assignment_tracking_operations_no_update", + "typed_ticket_assignment_tracking_operations_no_delete", + "ticket_current_singleton_role_idx", + "typed_ticket_assignment_identity_no_update", + "typed_ticket_assignment_identity_no_delete", + "typed_ticket_assignment_events_no_update", + "typed_ticket_assignment_events_no_delete", + "typed_ticket_assignment_receipt_no_update", + "typed_ticket_assignment_receipt_no_delete", + "typed_ticket_assignment_current_principal_insert", + "typed_ticket_assignment_current_principal_update", + "typed_ticket_assignment_orchestrator_principal_insert", + "typed_ticket_assignment_released_tracking_no_update", + "typed_ticket_assignment_parent_insert", + "typed_ticket_assignment_role_principal_insert", + "typed_ticket_assignment_audit_parent_insert", + "typed_ticket_assignment_operation_parent_insert", + ] { + let expected: String = canonical + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [name], + |row| row.get(0), + ) + .map_err(sqlite_err)?; + let actual: Option = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [name], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_err)?; + if actual.map(&normalize).as_deref() != Some(normalize(expected).as_str()) { + return Err(TicketError::Sqlite(format!( + "Ticket assignment tracking constraint {name} differs from canonical schema" + ))); + } + } + for (table, columns) in [ + ( + "ticket_worker_assignments", + vec![ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "assigned_by", + "assigned_at", + "tracking_json", + "last_tracking_event_id", + "tracking_updated_at", + "tracking_migration_diagnostic", + ], + ), + ( + "ticket_current_worker_assignments", + vec![ + "workspace_id", + "ticket_id", + "role", + "assignment_id", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "updated_at", + ], + ), + ( + "ticket_worker_assignment_events", + vec![ + "workspace_id", + "ticket_id", + "role", + "event_id", + "action", + "assignment_id", + "previous_assignment_id", + "actor", + "created_at", + "operation_id", + "reason", + ], + ), + ( + "ticket_assignment_operations", + vec![ + "workspace_id", + "operation_id", + "action", + "ticket_id", + "role", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "assignment_id", + "expected_assignment_id", + "created_at", + "request_fingerprint", + "claim_state", + "failure_reason", + "binding_recovery_json", + ], + ), + ( + "typed_ticket_assignment_tracking_history", + vec![ + "workspace_id", + "ticket_id", + "assignment_id", + "event_id", + "operation_key", + "tracking_json", + "source_context_json", + "at", + ], + ), + ( + "typed_ticket_assignment_tracking_operations", + vec![ + "workspace_id", + "operation_key", + "ticket_id", + "input_json", + "source_context_json", + "result_json", + ], + ), + ] { + let actual = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(sqlite_err)? + .query_map([], |r| r.get::<_, String>(1)) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + if actual != columns { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} has unexpected columns" + ))); + } + // Verify type, nullability, defaults and key positions, not just names. + let shape = |db: &Connection| -> Result, i64)>> { + db.prepare(&format!("PRAGMA table_info({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err) + }; + if shape(conn)? != shape(&canonical)? { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} type/default/key constraints differ" + ))); + } + let foreign_keys = |db: &Connection| -> Result> { + db.prepare(&format!("PRAGMA foreign_key_list({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok(( + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(6)?, + )) + }) + .map_err(sqlite_err)? + .filter_map(|r| match r { + Ok(key) if key.0 == "workspaces" => None, + other => Some(other), + }) + .collect::, _>>() + .map_err(sqlite_err) + }; + if !foreign_keys(&canonical)?.is_subset(&foreign_keys(conn)?) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} lacks canonical foreign keys" + ))); + } + let expected_sql: String = canonical + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [table], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + let actual_sql: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [table], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + let expected_sql = normalize(expected_sql); + let actual_sql = normalize(actual_sql); + let mut remainder = expected_sql.as_str(); + while let Some(start) = remainder.find("check(") { + let check = &remainder[start..]; + let mut depth = 0i64; + let mut end = None; + for (index, ch) in check.char_indices().skip(5) { + if ch == '(' { + depth += 1; + } else if ch == ')' { + depth -= 1; + if depth == 0 { + end = Some(index + 1); + break; + } + } + } + let end = end.ok_or_else(|| { + TicketError::Sqlite("canonical assignment CHECK is malformed".into()) + })?; + if !actual_sql.contains(&check[..end]) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} CHECK constraint differs" + ))); + } + remainder = &check[end..]; + } + let index_keys = |db: &Connection| -> Result)>> { + let indices = db + .prepare(&format!("PRAGMA index_list({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok(( + r.get::<_, String>(1)?, + r.get::<_, bool>(2)?, + r.get::<_, bool>(4)?, + )) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + indices + .into_iter() + .map(|(name, unique, partial)| { + let columns = db + .prepare(&format!("PRAGMA index_info({name})")) + .map_err(sqlite_err)? + .query_map([], |r| r.get::<_, String>(2)) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + Ok((unique, partial, columns)) + }) + .collect() + }; + if !index_keys(&canonical)?.is_subset(&index_keys(conn)?) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} lacks canonical key/index constraints" + ))); + } + let violations: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM pragma_foreign_key_check('{table}')"), + [], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + if violations != 0 { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} foreign key violations" + ))); + } + } + for name in [ + "typed_ticket_assignment_tracking_initialize", + "typed_ticket_assignment_tracking_assigned", + "typed_ticket_assignment_tracking_history_no_update", + "typed_ticket_assignment_tracking_history_no_delete", + "typed_ticket_assignment_tracking_operations_no_update", + "typed_ticket_assignment_tracking_operations_no_delete", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='trigger' AND name=?1)", + [name], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + if !exists { + return Err(TicketError::Sqlite(format!( + "Ticket assignment tracking constraint {name} missing" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "assignment_tracking_tests.rs"] +mod tests; diff --git a/crates/ticket/src/assignment_tracking_schema.sql b/crates/ticket/src/assignment_tracking_schema.sql new file mode 100644 index 000000000..71bdb0ac1 --- /dev/null +++ b/crates/ticket/src/assignment_tracking_schema.sql @@ -0,0 +1,128 @@ +-- Applied only by component 10 -> 11, never by request handlers. +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_json TEXT + CHECK(tracking_json IS NULL OR (role = 'orchestrator' AND json_valid(tracking_json) AND json_type(tracking_json)='object' + AND COALESCE(json_extract(tracking_json, '$.status') IN ('waiting', 'inprogress', 'done'), 0))); +ALTER TABLE ticket_worker_assignments ADD COLUMN last_tracking_event_id TEXT; +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_updated_at TEXT; +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_migration_diagnostic TEXT; +CREATE TABLE typed_ticket_assignment_tracking_history ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + event_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + tracking_json TEXT NOT NULL CHECK(json_valid(tracking_json)), + source_context_json TEXT NOT NULL CHECK(json_valid(source_context_json)), + at TEXT NOT NULL, + PRIMARY KEY(workspace_id, event_id), + UNIQUE(workspace_id, assignment_id, operation_key), + FOREIGN KEY(workspace_id, ticket_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) +); +CREATE TABLE typed_ticket_assignment_tracking_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + ticket_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, operation_key) +); +CREATE TRIGGER typed_ticket_assignment_tracking_initialize +AFTER INSERT ON ticket_worker_assignments WHEN NEW.role = 'orchestrator' +BEGIN + UPDATE ticket_worker_assignments SET tracking_json = '{"status":"waiting","waiting_reason":"Initial judgment pending","progress":null,"next_action":null,"result":null}', tracking_updated_at = NEW.assigned_at + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id; +END; +CREATE TRIGGER typed_ticket_assignment_tracking_assigned +AFTER INSERT ON ticket_worker_assignment_events +WHEN NEW.role = 'orchestrator' AND NEW.action IN ('assigned', 'reassigned') +BEGIN + UPDATE ticket_worker_assignments SET last_tracking_event_id = NEW.event_id, tracking_updated_at = NEW.created_at + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL; + INSERT INTO typed_ticket_assignment_tracking_history + SELECT workspace_id, ticket_id, assignment_id, NEW.event_id, COALESCE(NEW.operation_id, NEW.event_id), tracking_json, + json_object('actor', NEW.actor, 'kind', 'assignment'), NEW.created_at + FROM ticket_worker_assignments + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL; +END; +CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update +BEFORE UPDATE ON typed_ticket_assignment_tracking_history +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_history_no_delete +BEFORE DELETE ON typed_ticket_assignment_tracking_history +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_update +BEFORE UPDATE ON typed_ticket_assignment_tracking_operations +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_delete +BEFORE DELETE ON typed_ticket_assignment_tracking_operations +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END; +-- Original assignment identity/principal/audit is append-only. Tracking is the +-- only mutable assignment value; startup claims are mutable only while pending. +CREATE TRIGGER typed_ticket_assignment_identity_no_update +BEFORE UPDATE OF workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at ON ticket_worker_assignments +WHEN OLD.workspace_id IS NOT NEW.workspace_id OR OLD.ticket_id IS NOT NEW.ticket_id + OR OLD.assignment_id IS NOT NEW.assignment_id OR OLD.role IS NOT NEW.role + OR OLD.principal_kind IS NOT NEW.principal_kind OR OLD.principal_id IS NOT NEW.principal_id + OR OLD.runtime_id IS NOT NEW.runtime_id OR OLD.worker_id IS NOT NEW.worker_id + OR OLD.assigned_by IS NOT NEW.assigned_by OR OLD.assigned_at IS NOT NEW.assigned_at +BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_identity_no_delete BEFORE DELETE ON ticket_worker_assignments +BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_events_no_update BEFORE UPDATE ON ticket_worker_assignment_events +BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_events_no_delete BEFORE DELETE ON ticket_worker_assignment_events +BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_receipt_no_update BEFORE UPDATE ON ticket_assignment_operations +WHEN OLD.claim_state != 'pending' +BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_receipt_no_delete BEFORE DELETE ON ticket_assignment_operations +WHEN OLD.claim_state != 'pending' +BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_current_principal_insert BEFORE INSERT ON ticket_current_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id + AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role + AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id + AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id) + OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END; +CREATE TRIGGER typed_ticket_assignment_current_principal_update BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id + AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role + AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id + AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id) + OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END; +CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert BEFORE INSERT ON ticket_worker_assignments +WHEN NEW.role='orchestrator' AND (NEW.principal_kind!='workspace_agent' OR NEW.principal_id!='workspace-orchestrator') +BEGIN SELECT RAISE(ABORT, 'Orchestrator assignment requires the stable Workspace agent principal'); END; +CREATE TRIGGER typed_ticket_assignment_released_tracking_no_update +BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments +WHEN EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=OLD.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=OLD.assignment_id OR e.previous_assignment_id=OLD.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=OLD.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Released assignment tracking is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_parent_insert BEFORE INSERT ON ticket_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id) +BEGIN SELECT RAISE(ABORT, 'New assignment requires a Ticket in the same Workspace'); END; +CREATE TRIGGER typed_ticket_assignment_role_principal_insert BEFORE INSERT ON ticket_worker_assignments +WHEN (NEW.role='owner' AND NEW.principal_kind!='user') + OR (NEW.role='worker' AND NEW.principal_kind='workspace_agent') + OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator') +BEGIN SELECT RAISE(ABORT, 'Assignment role and principal are incompatible'); END; +CREATE TRIGGER typed_ticket_assignment_audit_parent_insert BEFORE INSERT ON ticket_worker_assignment_events +WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.assignment_id)) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.previous_assignment_id)) +BEGIN SELECT RAISE(ABORT, 'Assignment audit must match the same immutable Ticket role'); END; +CREATE TRIGGER typed_ticket_assignment_operation_parent_insert BEFORE INSERT ON ticket_assignment_operations +WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id) +BEGIN SELECT RAISE(ABORT, 'New assignment operation requires a Ticket in the same Workspace'); END; + +-- Equality scope keys leave SQLite's implicit rowid as stable append ordering. +CREATE INDEX typed_ticket_assignment_tracking_history_scope +ON typed_ticket_assignment_tracking_history(workspace_id,ticket_id,assignment_id); diff --git a/crates/ticket/src/assignment_tracking_tests.rs b/crates/ticket/src/assignment_tracking_tests.rs new file mode 100644 index 000000000..ca66d3636 --- /dev/null +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -0,0 +1,730 @@ +use super::*; + +fn db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + conn +} + +fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap(); +} + +fn assign(conn: &Connection, id: &str, event: &str) { + let previous: Option = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap(); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap(); + conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap(); + conn.execute_batch("COMMIT").unwrap(); +} + +fn source() -> TicketAssignmentTrackingSourceContext { + TicketAssignmentTrackingSourceContext { + actor: "orchestrator".into(), + runtime_id: "runtime".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "binding".into(), + } +} +fn update( + id: &str, + event: &str, + key: &str, + status: TicketAssignmentTrackingStatus, +) -> TicketAssignmentTrackingUpdate { + TicketAssignmentTrackingUpdate { + assignment_id: id.into(), + expected_last_event_id: event.into(), + operation_key: key.into(), + tracking: TicketAssignmentTracking { + status, + waiting_reason: None, + progress: Some("Considering request".into()), + next_action: None, + result: Some("Returned alternatives".into()), + }, + } +} +fn apply( + conn: &Connection, + input: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + let result = + sqlite_update_ticket_assignment_tracking_in_transaction(conn, "w", "t", input, source); + conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" }) + .unwrap(); + result +} + +#[test] +fn assignment_initializes_waiting_and_audit_atomically() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(projection.current); + assert_eq!(projection.last_event_id.as_deref(), Some("assigned-a")); + assert_eq!( + projection.tracking.unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].event_id, "assigned-a"); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap(); + conn.execute_batch("ROLLBACK").unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back") + .unwrap() + .is_none() + ); +} + +#[test] +fn direct_done_close_reopen_and_removal_keep_last_result_and_exact_replay() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let input = update( + "a", + "assigned-a", + "done", + TicketAssignmentTrackingStatus::Done, + ); + let result = apply(&conn, &input, &source()).unwrap(); + assert_eq!( + conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r + .get::<_, String>(0)) + .unwrap(), + "open" + ); + conn.execute("UPDATE typed_tickets SET workflow_state='closed'", []) + .unwrap(); + let input2 = update( + "a", + result.last_event_id.as_deref().unwrap(), + "correct-after-close", + TicketAssignmentTrackingStatus::Waiting, + ); + let corrected = apply(&conn, &input2, &source()).unwrap(); + assert_eq!( + conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r + .get::<_, String>(0)) + .unwrap(), + "closed" + ); + conn.execute("UPDATE typed_tickets SET workflow_state='open'", []) + .unwrap(); + assert_eq!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(), + corrected + ); + assign(&conn, "b", "assigned-b"); + assert_eq!(apply(&conn, &input, &source()).unwrap(), result); + assert_eq!(apply(&conn, &input2, &source()).unwrap(), corrected); + assert!( + apply( + &conn, + &update( + "a", + corrected.last_event_id.as_deref().unwrap(), + "old-new", + TicketAssignmentTrackingStatus::Done + ), + &source() + ) + .is_err() + ); + let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(!old.current); + assert_eq!(old.tracking, corrected.tracking); + let b = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap(); + assert_eq!( + b.tracking.unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + assert!( + apply( + &conn, + &update( + "b", + "assigned-b", + "removed-new", + TicketAssignmentTrackingStatus::Done + ), + &source() + ) + .is_err() + ); + assert!( + sqlite_current_ticket_assignment_tracking(&conn, "w", "t") + .unwrap() + .is_empty() + ); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a") + .unwrap() + .len(), + 3 + ); +} + +#[test] +fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let input = update( + "a", + "assigned-a", + "update", + TicketAssignmentTrackingStatus::Inprogress, + ); + let result = apply(&conn, &input, &source()).unwrap(); + assert_eq!(apply(&conn, &input, &source()).unwrap(), result); + let mut changed = input.clone(); + changed.tracking.result = Some("Different result".into()); + assert!(apply(&conn, &changed, &source()).is_err()); + changed = input.clone(); + changed.operation_key = "fresh-stale".into(); + assert!(apply(&conn, &changed, &source()).is_err()); + for change in 0..5 { + let mut sender = source(); + match change { + 0 => sender.actor = "other".into(), + 1 => sender.runtime_id = "other".into(), + 2 => sender.worker_id = "replacement".into(), + 3 => sender.authentication_binding_id = "replacement-binding".into(), + _ => sender.agent_key = "other-agent".into(), + }; + assert!(apply(&conn, &input, &sender).is_err()); + } + let mut other = source(); + other.worker_id = "replacement".into(); + // Backend authenticated the replacement as the current stable agent. A new + // operation can proceed on the same assignment without starting a new row. + let next = update( + "a", + result.last_event_id.as_deref().unwrap(), + "replacement-update", + TicketAssignmentTrackingStatus::Done, + ); + apply(&conn, &next, &other).unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert!( + sqlite_ticket_assignment_tracking(&conn, "other", "t", "a") + .unwrap() + .is_none() + ); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + sqlite_update_ticket_assignment_tracking_in_transaction( + &conn, + "other", + "t", + &input, + &source() + ) + .is_err() + ); + conn.execute_batch("ROLLBACK").unwrap(); + assert!( + conn.execute( + "UPDATE typed_ticket_assignment_tracking_history SET at='tamper'", + [] + ) + .is_err() + ); + assert!( + conn.execute( + "DELETE FROM typed_ticket_assignment_tracking_operations", + [] + ) + .is_err() + ); +} + +#[test] +fn tracking_reopens_database_without_restarting_or_reassigning_workers() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ticket.db"); + { + let conn = Connection::open(&path).unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + assign(&conn, "a", "assigned-a"); + apply( + &conn, + &update( + "a", + "assigned-a", + "done", + TicketAssignmentTrackingStatus::Done, + ), + &source(), + ) + .unwrap(); + } + let conn = Connection::open(&path).unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + sqlite_current_ticket_assignment_tracking(&conn, "w", "t").unwrap()[0] + .tracking + .as_ref() + .unwrap() + .status, + TicketAssignmentTrackingStatus::Done + ); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a") + .unwrap() + .len(), + 2 + ); +} + +fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-time');").unwrap(); + install_assignment_core(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + assign(&conn, "a", "assigned-a"); + let snapshot = TicketLegacyWorkflowSnapshot { + workflow_state: original.into(), + workflow_state_explicit: true, + status: original.into(), + queued_by: Some("author".into()), + queued_at: Some("original-queue-time".into()), + resolution: None, + }; + if original != "no-snapshot" { + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES('w','t',?1)", + [serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + } + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,to_state,at,body) VALUES('w','t',1,'state_changed','queued','original-queue-time','Queue')",[]).unwrap(); + if fenced { + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',1,'orchestrator_assignment_id','a')",[]).unwrap(); + } + if later_case { + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap(); + } + if reassigned { + assign(&conn, "b", "assigned-b"); + } + conn +} + +#[test] +fn migration_preserves_original_records_and_only_reconciles_current_fenced_delegation() { + for (original, fenced, later_case, reassigned, expected) in [ + ( + "queued", + true, + false, + false, + Some(TicketAssignmentTrackingStatus::Waiting), + ), + ( + "inprogress", + true, + false, + false, + Some(TicketAssignmentTrackingStatus::Inprogress), + ), + ("queued", false, false, false, None), + ("queued", true, true, false, None), + ("inprogress", true, false, true, None), + ("ready", true, false, false, None), + ("done", true, false, false, None), + ("closed", true, false, false, None), + ] { + let conn = legacy(original, fenced, later_case, reassigned); + let original_snapshot: String = conn + .query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot", + [], + |r| r.get(0), + ) + .unwrap(); + let original_event: String = conn + .query_row( + "SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + conn.query_row( + "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "original-time" + ); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 11 + ); + let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!( + value.tracking.as_ref().map(|t| t.status), + expected, + "{original}/{fenced}/{later_case}/{reassigned}" + ); + assert!(value.migration_diagnostic.is_some()); + assert_eq!( + conn.query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + original_snapshot + ); + assert_eq!( + conn.query_row( + "SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + original_event + ); + let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(), + history + ); + if reassigned { + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap() + .tracking + .is_none() + ); + } + } +} + +#[test] +fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap(); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err()); + conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap(); + assert!( + verify_sqlite_ticket_schema(&conn) + .unwrap_err() + .to_string() + .contains("differs from canonical schema") + ); + assert!(migrate_sqlite_ticket_schema(&conn).is_err()); +} + +#[test] +fn tracking_input_wire_contract_rejects_unknown_fields_and_statuses() { + let input = update( + "a", + "event", + "operation", + TicketAssignmentTrackingStatus::Inprogress, + ); + let mut value = serde_json::to_value(&input).unwrap(); + assert_eq!(value["tracking"]["status"], "inprogress"); + value["claimed_role"] = serde_json::json!("orchestrator"); + assert!(serde_json::from_value::(value).is_err()); + let mut value = serde_json::to_value(&input).unwrap(); + value["tracking"]["status"] = serde_json::json!("running"); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_pending_claims_can_finish() + { + let conn = db(); + assign(&conn, "a", "assigned-a"); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap(); + for sql in [ + "UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET assigned_at='forged-time' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET role='owner',principal_kind='user',principal_id='user' WHERE assignment_id='a'", + "DELETE FROM ticket_worker_assignments WHERE assignment_id='a'", + "UPDATE ticket_worker_assignment_events SET actor='forged'", + "UPDATE ticket_worker_assignment_events SET event_id='forged'", + "DELETE FROM ticket_worker_assignment_events", + "UPDATE ticket_assignment_operations SET request_fingerprint='forged' WHERE operation_id='assigned-op'", + "UPDATE ticket_assignment_operations SET created_at='forged' WHERE operation_id='assigned-op'", + "DELETE FROM ticket_assignment_operations WHERE operation_id='assigned-op'", + "UPDATE ticket_current_worker_assignments SET principal_id='another-agent'", + "UPDATE ticket_worker_assignments SET tracking_json='{}' WHERE assignment_id='a'", + ] { + assert!( + conn.execute(sql, []).is_err(), + "deny direct mutation: {sql}" + ); + } + // The immutable original principal also fences insertion of a mismatched + // current row, even in a standalone Ticket store without host constraints. + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err()); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap(); + conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap(); + conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap(); + assert!(conn.execute("UPDATE ticket_assignment_operations SET failure_reason='rewrite' WHERE operation_id='pending'",[]).is_err()); + assert!( + conn.execute( + "DELETE FROM ticket_assignment_operations WHERE operation_id='pending'", + [] + ) + .is_err() + ); + verify_sqlite_ticket_schema(&conn).unwrap(); +} + +#[test] +fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let result = apply( + &conn, + &update( + "a", + "assigned-a", + "result", + TicketAssignmentTrackingStatus::Done, + ), + &source(), + ) + .unwrap(); + assign(&conn, "b", "assigned-b"); + let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + let release = old.release.as_ref().unwrap(); + assert_eq!(release.action, TicketAssignmentReleaseAction::Replaced); + assert_eq!(release.replacement_assignment_id.as_deref(), Some("b")); + assert_eq!(release.last_tracking, result.tracking); + assert_eq!(release.actor, "author"); + assert_eq!(release.event_id, "assigned-b"); + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id,reason) VALUES('w','t','orchestrator','unassign-b','unassigned','b','human','original-unassign-time','unassign-op','Withdrawn')",[]).unwrap(); + let removed = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap(); + let release = removed.release.unwrap(); + assert_eq!(release.action, TicketAssignmentReleaseAction::Unassigned); + assert_eq!(release.at, "original-unassign-time"); + assert_eq!(release.reason.as_deref(), Some("Withdrawn")); + assert_eq!( + release.last_tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err()); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err()); + let legacy = legacy("done", true, false, false); + migrate_sqlite_ticket_schema(&legacy).unwrap(); + legacy + .execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + legacy.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id) VALUES('w','t','orchestrator','unassign-legacy','unassigned','a','human','legacy-unassign-time','legacy-unassign-op')",[]).unwrap(); + let original = sqlite_ticket_assignment_tracking(&legacy, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(original.tracking.is_none()); + assert!(original.release.unwrap().last_tracking.is_none()); +} + +fn routing_queue(conn: &Connection, index: i64, fence: &str) { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,from_state,to_state,at,state_field,reason,body) VALUES('w','t',?1,'state_changed','open','open','typed-queue-time','routing','queued','Explicit component-10 Queue')",[index]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'orchestrator_assignment_id',?2)",params![index,fence]).unwrap(); +} + +#[test] +fn migration_recognizes_typed_case_neutral_queue_and_never_revives_an_older_mismatched_queue() { + for original in ["ready", "no-snapshot"] { + let conn = legacy(original, true, false, false); + routing_queue(&conn, 2, "a"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!( + assignment.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert_eq!(assignment.updated_at.as_deref(), Some("typed-queue-time")); + } + let conn = legacy("queued", true, false, false); + routing_queue(&conn, 2, "mismatched-assignment"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .tracking + .is_none() + ); + let conn = legacy("no-snapshot", true, false, false); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .tracking + .is_none() + ); +} + +#[test] +fn migration_accepts_new_explicit_queue_after_ordered_case_judgments_but_not_after_a_later_case() { + for queue_after_case in [true, false] { + let conn = legacy("queued", true, false, false); + for (ordinal, index, action, state) in [ + ( + 0, + 2, + TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + "closed", + ), + (1, 3, TicketCaseAction::Reopen, "open"), + ] { + let key = format!("case-{ordinal}"); + let judgment = TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: if ordinal == 1 { + Some("case-0".into()) + } else { + None + }, + action, + explanation: "Original case decision".into(), + references: vec![], + author: "human".into(), + at: format!("case-time-{ordinal}"), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',?1,?2,'original-fingerprint',?3)",params![ordinal,key,serde_json::to_string(&judgment).unwrap()]).unwrap(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,state_field,to_state,at,body) VALUES('w','t',?1,'state_changed','case',?2,?3,'Original case decision')",params![index,state,judgment.at]).unwrap(); + conn.execute( + "INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'operation_key',?2)", + params![index, key], + ) + .unwrap(); + } + if queue_after_case { + routing_queue(&conn, 4, "a"); + } + let original_history: i64 = conn + .query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| { + r.get(0) + }) + .unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!(assignment.tracking.is_some(), queue_after_case); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + original_history + ); + } +} + +#[test] +fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_audit_and_operations() +{ + let conn = db(); + assign(&conn, "a", "assigned-a"); + for sql in [ + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')", + "INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')", + "INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')", + "INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')", + ] { + assert!(conn.execute(sql, []).is_err(), "reject {sql}"); + } + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignment_events", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + verify_sqlite_ticket_schema(&conn).unwrap(); +} + +#[test] +fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals() { + let conn = db(); + let sql:String=conn.query_row("SELECT sql FROM sqlite_schema WHERE name='typed_ticket_assignment_orchestrator_principal_insert'",[],|r|r.get(0)).unwrap(); + conn.execute_batch("DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert") + .unwrap(); + conn.execute_batch(&sql.replace("NEW.role='orchestrator'", "NEW.role='Orchestrator'")) + .unwrap(); + assert!( + verify_sqlite_ticket_schema(&conn) + .unwrap_err() + .to_string() + .contains("differs from canonical schema") + ); +} diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 3b8a29e4f..d65d78b1a 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -20,9 +20,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +mod assignment_tracking; pub mod config; mod sqlite_schema; mod tags; +pub use assignment_tracking::*; pub use tags::{ TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation, }; diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 1a8c022cd..96e38241c 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 10; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] @@ -307,10 +307,11 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } -/// Composes retained component 6/7, tags-only 8, or cases-only 9 into component 10. -/// The host owns the surrounding transaction (for example Workspace 90→91). +/// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then +/// appends the 10→11 assignment migration without replacing the applied 10 marker. +/// The host owns the surrounding transaction (for example Workspace 91→92). /// A savepoint rolls back this component on failure, without committing host work. -/// Current component 10 is verified only; immutable judgments are never reapplied. +/// Current component 11 is verified only; immutable judgments are never reapplied. pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> { if connection.is_autocommit() { return Err(TicketError::Sqlite( @@ -334,22 +335,30 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R create_latest_ticket_schema(connection)?; connection .execute( - "INSERT INTO ticket_schema_migrations VALUES (10, ?1, ?2)", + "INSERT INTO ticket_schema_migrations VALUES (11, ?1, ?2)", params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()], ) .map_err(sqlite_err)?; } else { - let version = match applied.iter().next() { - Some((&version, name)) - if applied.len() == 1 - && name == BASELINE_MIGRATION_NAME - && matches!(version, 6..=10) => - { - version - } - _ => { - validate_applied_migrations(&applied)?; - unreachable!() + let version = if applied + == BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ]) { + 11 + } else { + match applied.iter().next() { + Some((&version, name)) + if applied.len() == 1 + && name == BASELINE_MIGRATION_NAME + && matches!(version, 6..=11) => + { + version + } + _ => { + validate_applied_migrations(&applied)?; + unreachable!() + } } }; if version == 6 { @@ -370,9 +379,9 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R } TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?, TicketSchemaShape::Cases9 => add_tag_schema(connection)?, - TicketSchemaShape::Composed10 => {} + TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {} } - if version != 10 { + if version < 10 { connection .execute( "UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1", @@ -380,6 +389,10 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R ) .map_err(sqlite_err)?; } + if version < 11 { + crate::assignment_tracking::migrate_assignment_tracking(connection)?; + connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; + } } verify_sqlite_ticket_schema(connection) })(); @@ -402,6 +415,7 @@ enum TicketSchemaShape { Tags8, Cases9, Composed10, + Tracking11, } impl TicketSchemaShape { fn from_version(version: i64) -> Self { @@ -410,6 +424,7 @@ impl TicketSchemaShape { 8 => Self::Tags8, 9 => Self::Cases9, 10 => Self::Composed10, + 11 => Self::Tracking11, _ => unreachable!(), } } @@ -419,19 +434,20 @@ impl TicketSchemaShape { Self::Tags8 => 8, Self::Cases9 => 9, Self::Composed10 => 10, + Self::Tracking11 => 11, } } fn tags(self) -> bool { - matches!(self, Self::Tags8 | Self::Composed10) + matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11) } fn cases(self) -> bool { - matches!(self, Self::Cases9 | Self::Composed10) + matches!(self, Self::Cases9 | Self::Composed10 | Self::Tracking11) } } /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { - verify_ticket_schema_shape(connection, TicketSchemaShape::Composed10) + verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11) } /// Retained Workspace schema-66 migration produces the pre-classification v7 @@ -469,7 +485,12 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) Ok(applied) => { let history = if applied == BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())]) - { + || (matches!(shape, TicketSchemaShape::Tracking11) + && applied + == BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ])) { Ok(()) } else { Err(TicketError::Sqlite(format!( @@ -769,6 +790,13 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } + if matches!(shape, TicketSchemaShape::Tracking11) { + if let Err(error) = + crate::assignment_tracking::verify_assignment_tracking_schema(connection) + { + push_diagnostic(&mut diagnostics, error.to_string()); + } + } if diagnostics.is_empty() { Ok(()) } else { @@ -788,7 +816,8 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { connection .execute_batch(include_str!("latest_schema.sql")) - .map_err(sqlite_err) + .map_err(sqlite_err)?; + crate::assignment_tracking::migrate_assignment_tracking(connection) } /// Retained 7/8 case migration: leave tags and historical receipts untouched. @@ -1040,7 +1069,13 @@ fn validate_applied_migrations(applied: &BTreeMap) -> Result<()> { LATEST_SQLITE_TICKET_SCHEMA_VERSION, BASELINE_MIGRATION_NAME.to_string(), )]); - if applied == &expected { + if applied == &expected + || applied + == &BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ]) + { Ok(()) } else { Err(TicketError::Sqlite(format!( @@ -1541,7 +1576,10 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(10, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()) + ]) ); } @@ -1683,7 +1721,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 10 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1705,7 +1743,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 10 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index ae18dccbd..9d149688e 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -17,6 +17,7 @@ struct ModelTicketQueryItem { closure_operation_key: Option, current_closure: Option, legacy_handoff: Option, + assignment_tracking: Vec, tags: Vec, readiness: Option, priority: Option, @@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail { closure_operation_key: Option, current_closure: Option, legacy_handoff: Option, + assignment_tracking: Vec, tags: Vec, readiness: Option, priority: Option, @@ -98,6 +100,21 @@ pub(super) struct ModelObjectiveDetail { event_page: ModelObjectiveEventPage, } +#[derive(Debug, Serialize)] +struct ModelAssignmentTracking { + assignment_id: String, + role: String, + principal: ticket::TicketAssignmentPrincipalSnapshot, + assigned_by: String, + assigned_at: String, + release: Option, + current: bool, + tracking: Option, + last_event_id: Option, + updated_at: Option, + migration_diagnostic: Option, +} + #[derive(Debug, Serialize)] struct ModelWorkerSummary { worker: String, @@ -266,6 +283,7 @@ fn project_ticket_query_item(value: &Value) -> Result Result( .ok_or_else(|| format!("missing or invalid {key}")) } +fn project_assignment_tracking( + object: &Map, +) -> Result, String> { + let values = array_field(object, "assignment_tracking")?; + if values.len() > 1 { + return Err("Ticket has more than one current Orchestrator delegation".into()); + } + values + .iter() + .map(|value| { + let projection: ticket::TicketAssignmentTrackingProjection = + serde_json::from_value(value.clone()) + .map_err(|error| format!("invalid assignment tracking: {error}"))?; + Ok(ModelAssignmentTracking { + assignment_id: projection.assignment_id, + role: projection.role, + principal: projection.principal, + assigned_by: projection.assigned_by, + assigned_at: projection.assigned_at, + release: projection.release, + current: projection.current, + tracking: projection.tracking, + last_event_id: projection.last_event_id, + updated_at: projection.updated_at, + migration_diagnostic: projection.migration_diagnostic, + }) + }) + .collect() +} + fn project_tags(object: &Map) -> Result, String> { let values = array_field(object, "tags")?; if values.len() > 100 { @@ -770,7 +819,7 @@ mod tests { fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { for state in ["open", "closed"] { let projected = project_ticket_detail(json!({ - "resource_key": "T-718", "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", + "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], "linked_objectives": [], "assignments": [], "implementation_reports": [], @@ -812,7 +861,7 @@ mod tests { "queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null }); let response = json!({ - "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], + "assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "state": "open", "closure_operation_key": "close-1", "current_closure": null, "closure_history": history, "legacy_handoff": legacy, @@ -857,7 +906,7 @@ mod tests { "linked_tickets": ["00001M0E82D1V"], "linked_ticket_summaries": [{ "id": "00001M0E82D1V", - "resource_key": "T-496", "tags": [], + "resource_key": "T-496", "assignment_tracking": [], "tags": [], "title": "Ticket", "state": "closed", "updated_at": "2026-01-02T00:00:00Z" @@ -888,7 +937,7 @@ mod tests { "record_authority": "workspace_sqlite", "items": [{ "id": "00001TICKETINTERNAL", - "resource_key": "T-543", "tags": [], + "resource_key": "T-543", "assignment_tracking": [], "tags": [], "title": "Ticket", "state": "open", "closure_operation_key": null, "current_closure": null, "legacy_handoff": null, @@ -960,7 +1009,7 @@ mod tests { fn ticket_detail_projection_accepts_current_blocker_shape() { let projected = project_ticket_detail(json!({ "id": "internal-ticket", - "resource_key": "T-588", "tags": [], + "resource_key": "T-588", "assignment_tracking": [], "tags": [], "content_digest": "ticket-content-digest", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Queued Submit", @@ -1064,7 +1113,7 @@ mod tests { "review_excerpt": "Reviewed source" }); let projected = project_ticket_detail(json!({ - "resource_key": "T-716", "tags": [], + "resource_key": "T-716", "assignment_tracking": [], "tags": [], "content_digest": "current-ticket-content-digest", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Source evidence", @@ -1246,13 +1295,14 @@ mod tests { assert!(error.contains("W-")); } #[test] - fn ticket_query_and_detail_preserve_classification_separately_from_closure_judgment() { + fn ticket_query_and_detail_preserve_case_classification_and_delegation_independently() { + let tracking = json!({"workspace_id":"ws", "ticket_id":"internal-id", "assignment_id":"delegation", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"workspace-user:alice", "assigned_at":"2026-01-01T00:00:00Z", "release":null, "current":true, "last_event_id":"reported", "updated_at":"2026-01-01T00:00:00Z", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"Human answer", "progress":null, "next_action":"Compare the response", "result":null}}); let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}); let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); let detail = json!({ "resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1", - "state":"closed", "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, + "state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] }); let mut item = detail.clone(); @@ -1288,6 +1338,14 @@ mod tests { "query {field}" ); } + for projected in [&projected_detail, &projected_query["tickets"][0]] { + let actual = &projected["assignment_tracking"][0]; + assert_eq!(actual["assignment_id"], "delegation"); + assert_eq!(actual["last_event_id"], "reported"); + assert_eq!(actual["tracking"], tracking["tracking"]); + assert!(actual.get("workspace_id").is_none()); + assert!(actual.get("ticket_id").is_none()); + } assert_eq!(projected_detail["closure_history"], history); assert_eq!(projected_detail["content_digest"], "content-1"); // Unknown observations must not be replaced with empty/null values. diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 1a3ced7c6..a6e6e527a 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_tracking; mod tags; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -443,6 +444,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_tracking::TOOL_NAMES.contains(&name) => { + assignment_tracking::description(name).into() + } name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(), _ => ticket_tool_description(name, None), } @@ -576,6 +580,12 @@ impl TicketFeatureAccess { .chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| { *name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread })) + .chain( + assignment_tracking::TOOL_NAMES + .iter() + .copied() + .filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow), + ) .collect() } @@ -592,7 +602,13 @@ impl TicketFeatureAccess { } } -const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"]; +const READ_ONLY_TOOL_NAMES: &[&str] = &[ + "QueryTicket", + "ShowTicket", + "WorkspaceTagList", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", +]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -625,6 +641,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "WorkspaceTagRename", "WorkspaceTagDelete", "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", ]; #[cfg(test)] @@ -643,6 +661,9 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "WorkspaceTagRename", "WorkspaceTagDelete", "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentTrackingUpdate", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -1515,6 +1536,24 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, + "TicketAssignmentTrackingQuery" => NativeTicketOperation { + operation: "assignment_tracking_query", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentTrackingHistory" => NativeTicketOperation { + operation: "assignment_tracking_history", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentTrackingUpdate" => NativeTicketOperation { + operation: "assignment_tracking_update", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: true, + }, "TicketCaseUpdate" => NativeTicketOperation { operation: "case_update", surface: NativeTicketSurface::Item, @@ -1604,6 +1643,15 @@ pub(crate) fn enabled_ticket_definitions( definitions.push(tags::definition(client.clone(), kind)); } } + for kind in [ + assignment_tracking::TrackingToolKind::Query, + assignment_tracking::TrackingToolKind::History, + assignment_tracking::TrackingToolKind::Update, + ] { + if allowed.contains(&kind.name()) { + definitions.push(assignment_tracking::definition(client.clone(), kind)); + } + } definitions } @@ -2700,23 +2748,10 @@ mod tests { .collect::>(); assert_eq!( authoring_operations, - [ - "create", - "edit", - "query", - "read", - "comment", - "mark_ready", - "queue", - "case_update", - "record_relation", - "remove_relation", - "tag_list", - "tag_create", - "tag_rename", - "tag_delete", - "tags_set", - ] + WORKSPACE_AUTHORING_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); let item = ticket_descriptor( &authoring @@ -2753,22 +2788,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - [ - "query", - "read", - "comment", - "case_update", - "dependency_check", - "record_relation", - "remove_relation", - "record_orchestration_plan", - "query_orchestration_plans", - "tag_list", - "tag_create", - "tag_rename", - "tag_delete", - "tags_set", - ] + WORKFLOW_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); assert_eq!( native_ticket_tools(client, TicketFeatureAccess::review()) @@ -2776,7 +2799,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - ["query", "read", "tag_list"] + READ_ONLY_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); } diff --git a/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs new file mode 100644 index 000000000..349d1aacf --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs @@ -0,0 +1,466 @@ +//! Self-reported handling of one immutable delegation. This transport never +//! supplies actor/role authority or converts tracking into execution lifecycle. +use super::*; + +pub(super) const TOOL_NAMES: &[&str] = &[ + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentTrackingUpdate", +]; + +#[derive(Clone, Copy)] +pub(super) enum TrackingToolKind { + Query, + History, + Update, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct QueryInput { + /// Ticket reference, normally T-*. + ticket: String, + /// Omit for the current delegation; set to inspect a retained assignment and release. + assignment_id: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct HistoryInput { + ticket: String, + /// Immutable delegation identity, including released or replaced assignments. + assignment_id: String, + /// Continue after an event identity returned by this assignment's history. + after_event_id: Option, + #[schemars(range(min = 1, max = 100))] + limit: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct UpdateInput { + ticket: String, + assignment_id: String, + /// Last event observed for this exact delegation, not Ticket status or Worker state. + expected_last_event_id: String, + /// Reuse the same key and complete input after an uncertain result. + operation_key: String, + tracking: ticket::TicketAssignmentTracking, +} + +impl TrackingToolKind { + pub(super) fn name(self) -> &'static str { + match self { + Self::Query => TOOL_NAMES[0], + Self::History => TOOL_NAMES[1], + Self::Update => TOOL_NAMES[2], + } + } + fn schema(self) -> Value { + match self { + Self::Query => json!(schemars::schema_for!(QueryInput)), + Self::History => json!(schemars::schema_for!(HistoryInput)), + Self::Update => json!(schemars::schema_for!(UpdateInput)), + } + } +} + +pub(super) fn description(name: &str) -> &'static str { + match name { + "TicketAssignmentTrackingQuery" => { + "Read the current Ticket delegation, or one retained assignment_id with its role/principal, Assign actor/time and separate release/replacement record, and durable self-reported waiting/inprogress/done, last event identity, waiting reason, progress, next action and result. Tracking is independent of Ticket open/closed, Worker running/idle/stop, MR and delivery state." + } + "TicketAssignmentTrackingHistory" => { + "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." + } + _ => unreachable!("unknown assignment tracking tool"), + } +} + +fn required(name: &str, value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" + ))); + } + Ok(()) +} + +fn encode_segment(value: &str) -> String { + value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + char::from(byte).to_string() + } else { + format!("%{byte:02X}") + } + }) + .collect() +} + +#[derive(Clone)] +struct TrackingTool { + client: Arc, + kind: TrackingToolKind, +} + +#[async_trait] +impl Tool for TrackingTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + let workspace = self + .client + .workspace_id() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + ToolError::InvalidArgument( + "Assignment tracking requires Backend Workspace identity; no local fallback" + .into(), + ) + })?; + let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace)); + let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string()); + let (method, path, body) = match self.kind { + TrackingToolKind::Query => { + let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?; + required("ticket", &input.ticket)?; + let mut path = format!( + "{prefix}/{}/assignment-tracking", + encode_segment(&input.ticket) + ); + if let Some(assignment_id) = input.assignment_id { + required("assignment_id", &assignment_id)?; + path.push_str(&format!( + "?assignment_id={}", + encode_segment(&assignment_id) + )); + } + (WorkspaceRequestMethod::Get, path, None) + } + TrackingToolKind::History => { + let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?; + required("ticket", &input.ticket)?; + required("assignment_id", &input.assignment_id)?; + let limit = input.limit.unwrap_or(100); + if !(1..=100).contains(&limit) { + return Err(ToolError::InvalidArgument( + "limit must be between 1 and 100".into(), + )); + } + let mut path = format!( + "{prefix}/{}/assignment-tracking/history?assignment_id={}&limit={limit}", + encode_segment(&input.ticket), + encode_segment(&input.assignment_id) + ); + if let Some(after) = input.after_event_id { + required("after_event_id", &after)?; + path.push_str(&format!("&after_event_id={}", encode_segment(&after))); + } + (WorkspaceRequestMethod::Get, path, None) + } + TrackingToolKind::Update => { + let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?; + for (name, value) in [ + ("ticket", &input.ticket), + ("assignment_id", &input.assignment_id), + ("expected_last_event_id", &input.expected_last_event_id), + ("operation_key", &input.operation_key), + ] { + required(name, value)?; + } + // Unknown fields (including author, role, runtime and worker) are + // rejected before transport; authenticated context belongs to Backend. + let body = json!({"assignment_id": input.assignment_id, "expected_last_event_id": input.expected_last_event_id, "operation_key": input.operation_key, "tracking": input.tracking}); + ( + WorkspaceRequestMethod::Post, + format!( + "{prefix}/{}/assignment-tracking", + encode_segment(&input.ticket) + ), + Some(body.to_string()), + ) + } + }; + let request = match body { + Some(body) => WorkspaceRequest::json(method, path, body), + None => WorkspaceRequest::get(path), + }; + let response = self + .client + .execute(request) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + let detail: String = response.body.chars().take(1024).collect(); + return Err(ToolError::ExecutionFailed(format!( + "Assignment tracking request failed with HTTP status {}: {}", + response.status, detail + ))); + } + if response.body.len() > 1024 * 1024 { + return Err(ToolError::ExecutionFailed( + "Assignment tracking response exceeds 1 MiB; request a smaller history page".into(), + )); + } + let content: Value = serde_json::from_str(&response.body).map_err(|error| { + ToolError::ExecutionFailed(format!( + "Backend returned invalid assignment tracking JSON: {error}" + )) + })?; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some(content.to_string()), + attachments: Vec::new(), + }) + } +} + +pub(super) fn definition( + client: Arc, + kind: TrackingToolKind, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(description(kind.name())) + .input_schema(kind.schema()), + Arc::new(TrackingTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + + #[derive(Debug)] + struct Transport { + workspace: Option, + response: WorkspaceResponse, + requests: Mutex>, + } + impl WorkspaceClient for Transport { + fn workspace_id(&self) -> Option<&str> { + self.workspace.as_deref() + } + fn kind(&self) -> &str { + "tracking-contract-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(self.response.clone()) + } + } + fn transport(status: u16, body: &str) -> Arc { + Arc::new(Transport { + workspace: Some("workspace".into()), + response: WorkspaceResponse { + status, + body: body.into(), + }, + requests: Mutex::new(Vec::new()), + }) + } + fn update() -> Value { + json!({"ticket":"T-741", "assignment_id":"delegation", "expected_last_event_id":"assigned-event", "operation_key":"report", "tracking":{"status":"done", "waiting_reason":null, "progress":"Alternatives compared", "next_action":"Human decision", "result":"Returned options"}}) + } + + #[tokio::test] + async fn assignment_tracking_update_requires_observed_identity_and_excludes_actor_claims() { + let client = transport( + 200, + r#"{"assignment_id":"delegation","last_event_id":"reported-event","tracking":{"status":"done"}}"#, + ); + let (meta, tool) = definition(client.clone(), TrackingToolKind::Update)(); + assert_eq!(meta.name, "TicketAssignmentTrackingUpdate"); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&update())); + for field in [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking", + ] { + let mut input = update(); + input.as_object_mut().unwrap().remove(field); + assert!(!validator.is_valid(&input), "required {field}"); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + } + for field in [ + "author", + "role", + "runtime_id", + "worker_id", + "source_context", + ] { + let mut input = update(); + input[field] = json!("orchestrator"); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "reject {field}" + ); + } + assert!(client.requests.lock().unwrap().is_empty()); + for _ in 0..2 { + let output = tool + .execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(&output.content.unwrap()).unwrap()["last_event_id"], + "reported-event" + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests[0], requests[1], + "uncertain retry preserves full input" + ); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-741/assignment-tracking" + ); + let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); + assert_eq!(body["tracking"]["status"], "done"); + assert!(body.get("ticket").is_none()); + assert!(body.get("author").is_none()); + } + + #[tokio::test] + async fn assignment_tracking_reads_are_bounded_and_carry_history_cursor() { + let client = transport(200, r#"{"items":[],"next_event_id":null}"#); + let (_, tool) = definition(client.clone(), TrackingToolKind::History)(); + for limit in [0, 101] { + assert!( + tool.execute( + &json!({"ticket":"T-741", "assignment_id":"a", "limit":limit}).to_string(), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + } + tool.execute( + &json!({"ticket":"T-741", "assignment_id":"a", "after_event_id":"event/1", "limit":10}) + .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 + .ends_with("?assignment_id=a&limit=10&after_event_id=event%2F1") + ); + drop(requests); + let oversized = transport(200, &"x".repeat(1024 * 1024 + 1)); + let (_, tool) = definition(oversized, TrackingToolKind::Query)(); + assert!( + tool.execute(r#"{"ticket":"T-741"}"#, ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("1 MiB") + ); + } + + #[tokio::test] + async fn retained_assignment_query_encodes_identity_without_changing_workspace_scope() { + let client = transport( + 200, + r#"[{"assignment_id":"old/delegation?x","current":false,"release":{"action":"replaced"}}]"#, + ); + let (_, tool) = definition(client.clone(), TrackingToolKind::Query)(); + let output = tool + .execute( + r#"{"ticket":"T-741","assignment_id":"old/delegation?x"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let content: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(content[0]["current"], false); + assert_eq!(content[0]["release"]["action"], "replaced"); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-741/assignment-tracking?assignment_id=old%2Fdelegation%3Fx" + ); + } + + #[tokio::test] + async fn assignment_tracking_requires_backend_and_propagates_authority_rejection() { + let client = Arc::new(Transport { + workspace: None, + response: WorkspaceResponse { + status: 200, + body: "{}".into(), + }, + requests: Mutex::new(Vec::new()), + }); + let (_, tool) = definition(client.clone(), TrackingToolKind::Update)(); + assert!( + tool.execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("Backend Workspace identity") + ); + assert!(client.requests.lock().unwrap().is_empty()); + let client = transport(403, r#"{"code":"current_orchestrator_required"}"#); + let (_, tool) = definition(client, TrackingToolKind::Update)(); + assert!( + tool.execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("403") + ); + } + + #[test] + fn assignment_tracking_tools_share_native_wip_and_access_contract() { + let client = transport(200, "{}"); + let definitions = enabled_ticket_definitions(client, TicketFeatureAccess::workflow()); + for name in TOOL_NAMES { + let (meta, _) = definitions + .iter() + .find(|definition| definition().0.name == *name) + .unwrap()(); + let native = native_ticket_operation(name).unwrap(); + assert_eq!(native.identity_field, Some("ticket")); + assert_eq!(native.mutating, *name == "TicketAssignmentTrackingUpdate"); + assert!( + meta.description.contains("delegation") + || meta.description.contains("assignment_id") + ); + } + let reads = TicketFeatureAccess::read_only().tool_names(); + assert!(reads.contains(&"TicketAssignmentTrackingQuery")); + assert!(reads.contains(&"TicketAssignmentTrackingHistory")); + assert!(!reads.contains(&"TicketAssignmentTrackingUpdate")); + } +} diff --git a/crates/worker/src/feature/builtin/ticket/tags/tests.rs b/crates/worker/src/feature/builtin/ticket/tags/tests.rs index 792ca3790..b1044b0e0 100644 --- a/crates/worker/src/feature/builtin/ticket/tags/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -110,7 +110,7 @@ async fn classification_tool_schemas_require_observations_and_reject_actor_claim #[tokio::test] async fn classification_reads_forward_id_filters_and_preserve_tag_observations() { - let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]}); + let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]}); let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}})); let (meta, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); @@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations() assert_eq!(body[field], input[field]); } } - let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); + let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); let client = recording(detail); let (_, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)(); diff --git a/crates/workspace-server/src/assignment_tracking.rs b/crates/workspace-server/src/assignment_tracking.rs new file mode 100644 index 000000000..c798df247 --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking.rs @@ -0,0 +1,173 @@ +//! Workspace adapter: Ticket owns the assignment/tracking schema and mutations. +//! Authenticated source is constructed by Backend after singleton resolution; +//! grants, work releases and startup recovery never derive from tracking status. +use super::*; + +/// Backend-authenticated attribution. Readable assigned_by/actor is audit +/// payload, not this authority context. Never decoded from model/API input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TicketOrchestratorAssignmentSourceContext { + Runtime { + runtime_id: String, + worker_id: String, + agent_key: String, + authentication_binding_id: String, + }, + Account { + account_id: String, + user_id: String, + }, +} + +pub(super) fn require_runtime_source( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, + worker_id: &str, + agent_key: &str, + binding_id: &str, +) -> Result<()> { + let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?; + if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source { + return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into())); + } + Ok(()) +} + +pub(super) fn require_assignment_source( + conn: &Connection, + workspace_id: &str, + source: &TicketOrchestratorAssignmentSourceContext, +) -> Result<()> { + match source { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id, + worker_id, + agent_key, + authentication_binding_id, + } => require_runtime_source( + conn, + workspace_id, + runtime_id, + worker_id, + agent_key, + authentication_binding_id, + ), + TicketOrchestratorAssignmentSourceContext::Account { + account_id, + user_id, + } => { + // Backend already authenticates Account/Workspace permission. Do not + // infer a model role or require a Runtime for account authoring. + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM accounts WHERE account_id=?1)", + [account_id], + |row| row.get(0), + )?; + if !exists || user_id.trim().is_empty() { + return Err(Error::WorkspacePermissionDenied( + "Assignment account source is not a valid Backend actor".into(), + )); + } + Ok(()) + } + } +} + +pub(super) fn update( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // Every call, including a receipt replay, fences the authenticated + // Runtime binding and singleton snapshot under the write lock. Assignment + // replacement/removal may be replayed, Worker replacement may not. + require_runtime_source( + &tx, + workspace_id, + &source.runtime_id, + &source.worker_id, + &source.agent_key, + &source.authentication_binding_id, + )?; + let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + &tx, + workspace_id, + ticket_id, + update, + source, + ) + .map_err(|error| Error::TicketAssignmentConflict(error.to_string()))?; + tx.commit()?; + Ok(result) + }) +} + +pub(super) fn get( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, assignment_id) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn current( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_current_ticket_assignment_tracking(conn, workspace_id, ticket_id) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn history( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking_history( + conn, + workspace_id, + ticket_id, + assignment_id, + ) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn history_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, +) -> Result { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking_history_page( + conn, + workspace_id, + ticket_id, + assignment_id, + after_event_id, + limit, + ) + .map_err(|error| match error { + ticket::TicketError::Conflict(message) => Error::InvalidInput(message), + other => Error::Store(other.to_string()), + }) + }) +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs new file mode 100644 index 000000000..3fd7f8b6b --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -0,0 +1,1486 @@ +// Included in store::tests to reuse the existing hermetic Workspace fixtures. +fn tracking_store_source() -> TicketAssignmentTrackingSourceContext { + TicketAssignmentTrackingSourceContext { + actor: "authenticated-worker:r/worker".into(), + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} + +async fn tracking_store_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { + let (directory, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "tracking", "open"); + store.with_conn(|conn| { + conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[])?; + conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY])?; + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','replacement','Replacement','normal','1','1')",[])?; + Ok(()) + }).unwrap(); + (directory, store) +} + +fn tracking_store_assign( + store: &SqliteWorkspaceStore, + id: &str, + operation: &str, +) -> TicketRoleAssignmentRecord { + let assignment = TicketRoleAssignmentRecord { + assignment_id: id.into(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..manual_worker_assignment("tracking", id) + }; + store + .set_current_ticket_role_assignment( + &assignment, + None, + &format!("event-{operation}"), + operation, + false, + ) + .unwrap() +} +fn tracking_store_input(id: &str, event: &str, key: &str) -> TicketAssignmentTrackingUpdate { + TicketAssignmentTrackingUpdate { + assignment_id: id.into(), + expected_last_event_id: event.into(), + operation_key: key.into(), + tracking: TicketAssignmentTracking { + status: TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: None, + next_action: None, + result: Some("Returned alternatives".into()), + }, + } +} + +#[tokio::test] +async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_binding_under_write_lock() + { + for change in ["replacement", "revoke", "binding-change", "remove"] { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let input = tracking_store_input("a", "event-assign-a", "result-a"); + let result = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + result + ); + // Model the authority change after authenticated source capture, before + // BEGIN IMMEDIATE on a second connection. Receipt presence cannot bypass it. + let second = SqliteWorkspaceStore::open(directory.path().join("workspace.db")).unwrap(); + second.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + match change { + "replacement" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;}, + "revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;}, + "binding-change" => {tx.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;}, + _ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;}, + } + tx.commit()?;Ok(()) + }).unwrap(); + assert!( + matches!( + store.update_ticket_assignment_tracking("w", "tracking", &input, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "reject receipt after {change}" + ); + let next = tracking_store_input( + "a", + result.last_event_id.as_deref().unwrap(), + "fresh-after-change", + ); + assert!( + matches!( + store.update_ticket_assignment_tracking("w", "tracking", &next, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "reject fresh input after {change}" + ); + assert_eq!( + store + .list_ticket_assignment_tracking_history("w", "tracking", "a") + .unwrap() + .len(), + 2 + ); + } +} + +#[tokio::test] +async fn tracking_store_same_principal_reissue_and_exact_retries_keep_original_result_and_release_audit() + { + let (directory, store) = tracking_store_fixture().await; + let first = tracking_store_assign(&store, "a", "assign-a"); + let input = tracking_store_input("a", "event-assign-a", "result-a"); + let source = tracking_store_source(); + let result = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + let second = tracking_store_assign(&store, "b", "assign-b"); + assert_eq!( + store + .set_current_ticket_role_assignment( + &second, + None, + "ignored-retry-event", + "assign-b", + false + ) + .unwrap(), + second + ); + assert_eq!( + store + .set_current_ticket_role_assignment( + &first, + None, + "ignored-original-retry", + "assign-a", + false + ) + .unwrap(), + first + ); + let old = store + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(); + assert!(!old.current); + assert_eq!(old.tracking, result.tracking); + assert_eq!(old.assigned_by, first.assigned_by); + assert_eq!(old.assigned_at, first.assigned_at); + assert_eq!(old.role, "orchestrator"); + let release = old.release.as_ref().unwrap(); + assert_eq!( + release.action, + ticket::TicketAssignmentReleaseAction::Replaced + ); + assert_eq!(release.replacement_assignment_id.as_deref(), Some("b")); + assert_eq!(release.operation_key.as_deref(), Some("assign-b")); + assert_eq!(release.last_tracking, old.tracking); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + result + ); + assert!( + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("a", result.last_event_id.as_deref().unwrap(), "stale-new"), + &source + ) + .is_err() + ); + let current = store + .get_ticket_assignment_tracking("w", "tracking", "b") + .unwrap() + .unwrap(); + assert_eq!( + current.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert!( + store + .clear_current_ticket_role_assignment( + "w", + "tracking", + TicketAssignmentRole::Orchestrator, + "b", + "unassign-event", + "unassign-op", + "human", + "3", + Some("Returned to human") + ) + .unwrap() + ); + let removed = store + .get_ticket_assignment_tracking("w", "tracking", "b") + .unwrap() + .unwrap(); + assert_eq!( + removed.release.as_ref().unwrap().action, + ticket::TicketAssignmentReleaseAction::Unassigned + ); + assert_eq!( + removed.release.as_ref().unwrap().reason.as_deref(), + Some("Returned to human") + ); + assert_eq!(removed.tracking, current.tracking); + assert!( + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("b", "event-assign-b", "removed-new"), + &source + ) + .is_err() + ); + drop(store); + let reopened = SqliteWorkspaceStore::open(directory.path().join("workspace.db")).unwrap(); + assert_eq!( + reopened + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(), + old + ); + reopened + .with_conn(|conn| { + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + })?, + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_operations", + [], + |r| r.get::<_, i64>(0) + )?, + 3 + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn tracking_store_close_reopen_and_tracking_done_never_restore_grants_or_work_release() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + store.with_conn(|conn| {conn.execute("UPDATE typed_tickets SET workflow_state='closed' WHERE workspace_id='w' AND ticket_id='tracking'",[])?;Ok(())}).unwrap(); + let input = tracking_store_input("a", "event-assign-a", "closed-result"); + let done = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + store.with_conn(|conn| {assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='tracking'",[],|r|r.get::<_,String>(0))?,"closed");conn.execute("UPDATE typed_tickets SET workflow_state='open' WHERE workspace_id='w' AND ticket_id='tracking'",[])?;assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='a'",[],|r|r.get::<_,i64>(0))?,1);assert_eq!(conn.query_row("SELECT COUNT(*) FROM worker_control_grants",[],|r|r.get::<_,i64>(0))?,0);Ok(())}).unwrap(); + assert!( + store + .get_active_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(), + done + ); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + done + ); +} + +fn tracking_schema91_fixture(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Genuine retained component-10 DDL, before assignment tracking existed. + // The host structure is unchanged from Workspace 91: no tracking columns, + // tracking tables/triggers, or component-11 marker are created here. + conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + create_workspace_schema_structure(&conn).unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(91,'workspace schema baseline')", + [], + ) + .unwrap(); + conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;") + .unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('tracking-owner','user','tracking-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('w','tracking-owner','Workspace','active','1','1'); INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','worker','Worker','normal','1','1');").unwrap(); + for (ticket, state, snapshot, fence) in [ + ("pending", "open", Some("queued"), Some("a-pending")), + ("progress", "open", Some("inprogress"), Some("a-progress")), + ( + "invalid-fence", + "open", + Some("queued"), + Some("not-the-current-identity"), + ), + ("no-snapshot", "open", None, Some("a-no-snapshot")), + ( + "closed-reopen", + "open", + Some("queued"), + Some("a-closed-reopen"), + ), + ("replaced", "open", Some("queued"), Some("a-replaced")), + ( + "historical-done", + "closed", + Some("done"), + Some("a-historical-done"), + ), + ] { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w',?1,?1,'Title',?2,'task','P2','Original body',?2,1)",params![ticket,state]).unwrap(); + let assignment = format!("a-{ticket}"); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','original-assign-actor','2026-10-10T00:00:00Z')",params![ticket,assignment]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','2026-10-10T00:00:00Z')",params![ticket,assignment]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('w',?1,'orchestrator',?2,'assigned',?3,'original-assign-actor','2026-10-10T00:00:00Z',?4)",params![ticket,format!("assigned-{ticket}"),assignment,format!("original-op-{ticket}")]).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w',?1,'assign',?2,'orchestrator','workspace_agent','workspace-orchestrator',?3,'2026-10-10T00:00:00Z','original-operation-fingerprint')",params![format!("original-op-{ticket}"),ticket,assignment]).unwrap(); + if let Some(state) = snapshot { + let original = ticket::TicketLegacyWorkflowSnapshot { + workflow_state: state.into(), + workflow_state_explicit: true, + status: state.into(), + queued_by: Some("original-queue-actor".into()), + queued_at: Some("2026-10-10T00:01:00Z".into()), + resolution: None, + }; + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES('w',?1,?2)", + params![ticket, serde_json::to_string(&original).unwrap()], + ) + .unwrap(); + } + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,body) VALUES('w',?1,0,'state_changed','original-queue-actor','2026-10-10T00:01:00Z','ready','queued','Original Queue intent')",[ticket]).unwrap(); + if let Some(fence) = fence { + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w',?1,0,'orchestrator_assignment_id',?2)",params![ticket,fence]).unwrap(); + } + } + // A pending worker startup reservation is operational data, not tracking. + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state,binding_recovery_json) VALUES('w','pending-startup-claim','assign','pending','worker','worker','r','worker','original-claim-time','pending-claim-fingerprint','pending','{}')",[]).unwrap(); + for (ordinal, action) in [ + ( + 0, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ), + (1, ticket::TicketCaseAction::Reopen), + ] { + let key = format!("original-case-{ordinal}"); + let history = ticket::TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: if ordinal == 1 { + Some("original-case-0".into()) + } else { + None + }, + action, + explanation: "Original later judgment".into(), + references: vec![], + author: "original-case-actor".into(), + at: format!("2026-10-10T00:0{}:00Z", ordinal + 2), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','closed-reopen',?1,?2,'original-case-fingerprint',?3)",params![ordinal,key,serde_json::to_string(&history).unwrap()]).unwrap(); + } + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','replaced','b-replaced','orchestrator','workspace_agent','workspace-orchestrator','original-reassign-actor','2026-10-10T00:05:00Z')",[]).unwrap(); + conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='replaced'",[]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','replaced','b-replaced','orchestrator','workspace_agent','workspace-orchestrator','2026-10-10T00:05:00Z')",[]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES('w','replaced','orchestrator','original-reassign-event','reassigned','b-replaced','a-replaced','original-reassign-actor','2026-10-10T00:05:00Z','original-reassign-op')",[]).unwrap(); + assert!(!table_exists(&conn, "typed_ticket_assignment_tracking_history").unwrap()); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 10 + ); + assert_eq!(current_schema_version(&conn).unwrap(), 91); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); + conn +} + +#[test] +fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receipts_and_claims() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("retained91.db"); + let conn = tracking_schema91_fixture(&path); + let retained = [ + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_legacy_workflow_snapshot", + "typed_ticket_case_history", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + ]; + let original = retained.map(|table| stored_fixture_rows(&conn, table)); + drop(conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 91); + assert_eq!( + plan.migrations + .iter() + .map(|m| m.version) + .collect::>(), + vec![92] + ); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 92); + assert_eq!( + conn.query_row( + "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", + [], + |r| r.get::<_, String>(0) + )?, + "original-component10-time" + ); + assert_eq!( + workspace_schema_migration_history(conn)?, + vec![ + (91, "workspace schema baseline".into()), + (92, "Ticket-owned assignment tracking".into()) + ] + ); + for (table, rows) in retained.iter().zip(original.iter()) { + assert_eq!( + &stored_fixture_rows(conn, table), + rows, + "preserve original {table}" + ); + } + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + ))?, + 0 + ); + Ok(()) + }) + .unwrap(); + for (ticket, status) in [ + ("pending", Some(TicketAssignmentTrackingStatus::Waiting)), + ("progress", Some(TicketAssignmentTrackingStatus::Inprogress)), + ("invalid-fence", None), + ("no-snapshot", None), + ("closed-reopen", None), + ("replaced", None), + ("historical-done", None), + ] { + let value = store + .get_ticket_assignment_tracking("w", ticket, &format!("a-{ticket}")) + .unwrap() + .unwrap(); + assert_eq!( + value.tracking.as_ref().map(|t| t.status), + status, + "{ticket}" + ); + assert_eq!(value.assigned_by, "original-assign-actor"); + assert_eq!(value.assigned_at, "2026-10-10T00:00:00Z"); + assert!(value.migration_diagnostic.is_some()); + } + let replaced = store + .get_ticket_assignment_tracking("w", "replaced", "a-replaced") + .unwrap() + .unwrap(); + assert_eq!( + replaced.release.as_ref().unwrap().event_id, + "original-reassign-event" + ); + assert!(replaced.release.as_ref().unwrap().last_tracking.is_none()); + let pending = store + .get_ticket_assignment_operation("w", "pending-startup-claim") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert_eq!( + pending.request_fingerprint.as_deref(), + Some("pending-claim-fingerprint") + ); + let history = store + .list_ticket_assignment_tracking_history("w", "pending", "a-pending") + .unwrap(); + drop(store); + let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + assert_eq!( + reopened + .list_ticket_assignment_tracking_history("w", "pending", "a-pending") + .unwrap(), + history + ); +} + +#[test] +fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first_self_report() { + use ticket::TicketBackend; + + for report_after_reopen in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("closed-unknown91.db"); + let conn = tracking_schema91_fixture(&path); + conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[]).unwrap(); + conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + conn.execute("INSERT INTO worker_control_grants(workspace_id,grant_id,controller_runtime_id,controller_worker_id,subject_runtime_id,subject_worker_id,relation,origin,permissions_json,operation_id,created_at,revoked_at) VALUES('w','old-revoked-grant','r','worker','r','worker','ticket','user','[\"observe\"]','old-grant-op','1','original-revocation')",[]).unwrap(); + let audit_before = stored_fixture_rows(&conn, "ticket_worker_assignment_events"); + let grants_before = stored_fixture_rows(&conn, "worker_control_grants"); + let releases_before = stored_fixture_rows(&conn, "ticket_assignment_work_releases"); + drop(conn); + + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let reopen = || { + let observed = backend.show("historical-done".into()).unwrap(); + backend + .update_case( + "historical-done", + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&observed), + expected_state: ticket::TicketState::Closed, + expected_closure_operation_key: observed.closure_operation_key.clone(), + operation_key: "explicit-reopen".into(), + action: ticket::TicketCaseAction::Reopen, + explanation: "Reopen without redelegation or restoring old work".into(), + references: vec![], + author: Some("account-author".into()), + }, + ) + .unwrap(); + }; + let unknown = store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(unknown.current); + assert!(unknown.tracking.is_none()); + assert!(unknown.updated_at.is_none()); + assert!(unknown.migration_diagnostic.is_some()); + assert_eq!( + unknown.last_event_id.as_deref(), + Some("assigned-historical-done") + ); + assert_eq!( + store + .list_current_ticket_assignment_tracking("w", "historical-done") + .unwrap(), + vec![unknown.clone()] + ); + assert!( + store + .list_ticket_assignment_tracking_history( + "w", + "historical-done", + "a-historical-done" + ) + .unwrap() + .is_empty() + ); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 92); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 11); + assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed"); + let stored: (Option, Option) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?; + assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage"); + assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before); + Ok(()) + }).unwrap(); + if report_after_reopen { + reopen(); + } + let source = tracking_store_source(); + let input = tracking_store_input( + "a-historical-done", + unknown.last_event_id.as_deref().unwrap(), + "first-explicit-result", + ); + let mut wrong_source = source.clone(); + wrong_source.worker_id = "not-current-orchestrator".into(); + assert!(matches!( + store.update_ticket_assignment_tracking("w", "historical-done", &input, &wrong_source), + Err(Error::WorkspacePermissionDenied(_)) + )); + for (workspace, ticket) in [("other-workspace", "historical-done"), ("w", "pending")] { + assert!( + store + .update_ticket_assignment_tracking(workspace, ticket, &input, &source) + .is_err() + ); + } + assert!( + store + .update_ticket_assignment_tracking( + "w", + "replaced", + &tracking_store_input( + "a-replaced", + "assigned-replaced", + "wrong-old-assignment" + ), + &source + ) + .is_err() + ); + // A second observer holds the same Assign baseline. Only the first + // explicit writer may consume it; BEGIN IMMEDIATE serializes the CAS. + let second = SqliteWorkspaceStore::open(&path).unwrap(); + assert_eq!( + second + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(), + unknown + ); + let done = store + .update_ticket_assignment_tracking("w", "historical-done", &input, &source) + .unwrap(); + assert_eq!( + done.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Done + ); + assert_eq!(done.assignment_id, unknown.assignment_id); + assert_ne!(done.last_event_id, unknown.last_event_id); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "historical-done", &input, &source) + .unwrap(), + done + ); + assert!(matches!( + second.update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + "assigned-historical-done", + "stale-second-observer" + ), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert_eq!( + backend + .show("historical-done".into()) + .unwrap() + .meta + .workflow_state, + if report_after_reopen { + ticket::TicketState::Open + } else { + ticket::TicketState::Closed + } + ); + if !report_after_reopen { + reopen(); + } + let after_reopen = store + .update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + done.last_event_id.as_deref().unwrap(), + "explicit-result-after-reopen", + ), + &source, + ) + .unwrap(); + let history = store + .list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done") + .unwrap(); + assert_eq!( + history.len(), + 2, + "only explicit self-reports create handling events" + ); + assert_eq!(history[0].event_id, done.last_event_id.clone().unwrap()); + assert_eq!( + history[1].event_id, + after_reopen.last_event_id.clone().unwrap() + ); + assert!( + store + .get_active_ticket_role_assignment( + "w", + "historical-done", + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND ticket_id='historical-done'", [], |row| row.get::<_,i64>(0))?, 1); + assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before); + assert_eq!(stored_fixture_rows(conn, "worker_control_grants"), grants_before); + assert_eq!(stored_fixture_rows(conn, "ticket_assignment_work_releases"), releases_before); + Ok(()) + }).unwrap(); + drop(second); + drop(store); + let reopened_store = SqliteWorkspaceStore::open(&path).unwrap(); + assert_eq!( + reopened_store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(), + after_reopen + ); + assert!( + reopened_store + .clear_current_ticket_orchestrator_assignment( + "w", + "historical-done", + "a-historical-done", + "explicit-release-event", + "explicit-release", + "human", + "3", + Some("Return responsibility"), + &orchestrator_mutation_runtime_source() + ) + .unwrap() + ); + assert!(matches!( + reopened_store.update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + after_reopen.last_event_id.as_deref().unwrap(), + "new-report-after-release" + ), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + let released = reopened_store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.tracking, after_reopen.tracking); + } +} + +#[test] +fn tracking_store_schema91_missing_assign_audit_remains_unknown_without_cas_baseline() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing-audit91.db"); + let conn = tracking_schema91_fixture(&path); + conn.execute( + "DELETE FROM ticket_worker_assignment_events WHERE event_id='assigned-historical-done'", + [], + ) + .unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let unknown = store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(unknown.current); + assert!(unknown.tracking.is_none()); + assert!(unknown.last_event_id.is_none()); + assert!(unknown.updated_at.is_none()); + assert!(unknown.migration_diagnostic.is_some()); + assert!( + store + .list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done") + .unwrap() + .is_empty() + ); + // Another assignment's real audit is not a substitute for missing evidence. + store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assert!( + ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + &tx, + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + "assigned-pending", + "not-a-baseline" + ), + &tracking_store_source() + ) + .is_err() + ); + Ok(()) + }) + .unwrap(); +} + +fn orchestrator_mutation_runtime_source() -> TicketOrchestratorAssignmentSourceContext { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} +fn orchestrator_mutation_record(id: &str) -> TicketRoleAssignmentRecord { + TicketRoleAssignmentRecord { + assignment_id: id.into(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + assigned_by: "readable Backend attribution".into(), + ..manual_worker_assignment("tracking", id) + } +} + +#[tokio::test] +async fn orchestrator_assignment_receipts_bind_verified_source_even_when_readable_actor_is_unchanged() + { + let (_directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("source-a"); + let assigned = store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "source-event", + "source-assign", + false, + &source, + ) + .unwrap(); + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &orchestrator_mutation_record("ignored-result-id"), + None, + "ignored-event", + "source-assign", + false, + &source + ) + .unwrap(), + assigned + ); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "source-clear-event", + "source-clear", + "readable clear actor", + "3", + Some("Returned"), + &source + ) + .unwrap() + ); + let second = orchestrator_mutation_record("source-b"); + store + .set_current_ticket_orchestrator_assignment( + &second, + None, + "second-event", + "second-assign", + false, + &source, + ) + .unwrap(); + // Exact old operations preserve original results after assignment movement. + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &source + ) + .unwrap(), + assigned + ); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-clear-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ) + .unwrap() + ); + assert!( + !store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "new-stale-event", + "new-stale-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ) + .unwrap() + ); + assert!( + store + .get_ticket_assignment_operation("w", "new-stale-clear") + .unwrap() + .is_none() + ); + store.with_conn(|conn| {conn.execute("UPDATE workspace_runtime_bindings SET binding_id='replacement-binding' WHERE workspace_id='w' AND runtime_id='r'",[])?;Ok(())}).unwrap(); + let new_source = TicketOrchestratorAssignmentSourceContext::Runtime { + authentication_binding_id: "replacement-binding".into(), + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + }; + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &new_source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &new_source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + )); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + )); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + })?, + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignment_events", + [], + |r| r.get::<_, i64>(0) + )?, + 3 + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn orchestrator_assignment_all_runtime_retries_fence_singleton_and_binding_in_write_transaction() + { + for change in ["replace", "revoke", "remove"] { + let (directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("source-a"); + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "assigned-event", + "assigned-operation", + false, + &source, + ) + .unwrap(); + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "clear-event", + "clear-operation", + "human-readable actor", + "3", + None, + &source, + ) + .unwrap(); + let second = SqliteWorkspaceStore::open(directory.path().join("workspace.db")).unwrap(); + second.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;match change { + "replace" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;}, + "revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;}, + _ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;}, + }tx.commit()?;Ok(())}).unwrap(); + assert!( + matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "retry-event", + "assigned-operation", + false, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + ), + "assign after {change}" + ); + assert!( + matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "retry-clear-event", + "clear-operation", + "human-readable actor", + "4", + None, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + ), + "clear after {change}" + ); + } +} + +#[tokio::test] +async fn orchestrator_assignment_account_authoring_is_trusted_source_bound_and_never_requires_a_runtime() + { + let (_directory, store) = tracking_store_fixture().await; + store + .with_conn(|conn| { + conn.execute("DELETE FROM worker_singleton_owners", [])?; + conn.execute( + "UPDATE workspace_runtime_bindings SET revoked_at='revoked'", + [], + )?; + Ok(()) + }) + .unwrap(); + let source = TicketOrchestratorAssignmentSourceContext::Account { + account_id: "owner-account".into(), + user_id: "authenticated-user".into(), + }; + let record = orchestrator_mutation_record("account-a"); + let assigned = store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "account-event", + "account-assign", + false, + &source, + ) + .unwrap(); + assert_eq!(assigned.assigned_by, "readable Backend attribution"); + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "account-assign", + false, + &source + ) + .unwrap(), + assigned + ); + let different = TicketOrchestratorAssignmentSourceContext::Account { + account_id: "owner-account".into(), + user_id: "different-authenticated-user".into(), + }; + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "account-assign", + false, + &different + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "account-a", + "account-clear-event", + "account-clear", + "account display actor", + "3", + None, + &source + ) + .unwrap() + ); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "account-a", + "ignored-event", + "account-clear", + "account display actor", + "4", + None, + &different + ), + Err(Error::TicketAssignmentConflict(_)) + )); +} + +#[tokio::test] +async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bounded_limits() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "history-a", "history-assign-a"); + let mut expected = vec!["event-history-assign-a".to_owned()]; + store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + for index in 0..204 { + let event=format!("reverse-event-{:03}",204-index);expected.push(event.clone()); + tx.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a',?1,?2,'{\"status\":\"waiting\"}','{\"kind\":\"fixture\"}','same-at')",params![event,format!("fixture-operation-{index}")])?; + }tx.commit()?;Ok(()) + }).unwrap(); + let first = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 100) + .unwrap(); + assert_eq!(first.items.len(), 100); + assert_eq!( + first + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[..100] + ); + assert_eq!(first.next_event_id.as_deref(), Some(expected[99].as_str())); + let second = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + first.next_event_id.as_deref(), + 100, + ) + .unwrap(); + assert_eq!(second.items.len(), 100); + assert_eq!( + second + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[100..200] + ); + let third = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + second.next_event_id.as_deref(), + 100, + ) + .unwrap(); + assert_eq!( + third + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[200..] + ); + assert!(third.next_event_id.is_none()); + let after_last = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + expected.last().map(String::as_str), + 1, + ) + .unwrap(); + assert!(after_last.items.is_empty()); + assert!(after_last.next_event_id.is_none()); + let one = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 1) + .unwrap(); + assert_eq!(one.items.len(), 1); + assert_eq!(one.next_event_id.as_deref(), Some(expected[0].as_str())); + for limit in [0, 101, usize::MAX] { + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + None, + limit + ), + Err(Error::InvalidInput(_)) + )); + } + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("missing"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + tracking_store_assign(&store, "history-b", "history-assign-b"); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("event-history-assign-b"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "other", + "tracking", + "history-a", + Some(&expected[0]), + 10 + ), + Err(Error::InvalidInput(_)) + )); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "wrong-ticket", + "history-a", + Some(&expected[0]), + 10 + ), + Err(Error::InvalidInput(_)) + )); + seed_manual_start_ticket(&store, "another-ticket", "open"); + let other_ticket = TicketRoleAssignmentRecord { + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..manual_worker_assignment("another-ticket", "other-ticket-assignment") + }; + store + .set_current_ticket_role_assignment( + &other_ticket, + None, + "other-ticket-cursor", + "other-ticket-operation", + false, + ) + .unwrap(); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("other-ticket-cursor"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "other".into(), + owner_account_id: "owner-account".into(), + display_name: "Other".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','','open',1)",[])?;Ok(())}).unwrap(); + let other_workspace = TicketRoleAssignmentRecord { + workspace_id: "other".into(), + ..orchestrator_mutation_record("history-a") + }; + store + .set_current_ticket_role_assignment( + &other_workspace, + None, + "other-workspace-cursor", + "other-workspace-operation", + false, + ) + .unwrap(); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("other-workspace-cursor"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + // Retained history can still be read after assignment replacement. + assert_eq!( + store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 100) + .unwrap(), + first + ); +} + +#[tokio::test] +async fn tracking_history_sql_limit_does_not_deserialize_unrequested_lifetime_rows() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "history-a", "history-assign-a"); + store.with_conn(|conn| { + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a','safe-lookahead','safe-operation','{\"status\":\"waiting\"}','{}','same-at')",[])?; + // Simulate a later imported/corrupt domain payload: valid SQLite JSON, + // but not a tracking DTO. It must not poison an earlier bounded page. + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a','later-corrupt-event','later-corrupt-operation','{\"status\":\"not-a-tracking-status\"}','{}','same-at')",[])?;Ok(()) + }).unwrap(); + let page = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 1) + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].event_id, "event-history-assign-a"); + assert_eq!( + page.next_event_id.as_deref(), + Some("event-history-assign-a") + ); + assert!( + store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("safe-lookahead"), + 1 + ) + .is_err() + ); +} + +#[tokio::test] +async fn orchestrator_clear_receipt_compares_nullable_reason_as_full_payload_not_only_readable_text() + { + let (_directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("reason-a"); + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "reason-assign-event", + "reason-assign", + false, + &source, + ) + .unwrap(); + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "reason-clear-event", + "reason-clear", + "actor", + "3", + None, + &source, + ) + .unwrap(); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "ignored-event", + "reason-clear", + "actor", + "4", + Some(""), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "ignored-event", + "reason-clear", + "actor", + "4", + None, + &source + ) + .unwrap() + ); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index dd4d30ebb..686be3115 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -238,6 +238,20 @@ impl SqliteWorkspaceAuthority { }) } + /// Assignment self-report enrichment is a separate projection so retained + /// responsibility and existing Ticket constructors stay distinct from status. + pub fn ticket_assignment_tracking( + &self, + reference: &str, + ) -> Result> { + let ticket = self + .ticket_backend + .show(TicketIdOrSlug::from(reference)) + .map_err(Error::from)?; + self.store + .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id) + } + pub fn with_source_commit_resolver( mut self, source_commit_resolver: Arc, @@ -905,7 +919,11 @@ impl SqliteWorkspaceAuthority { &blocker.blocking_ticket, )?; } + let assignment_tracking = self + .store + .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?; Ok(TicketDetail { + assignment_tracking, id: ticket.meta.id, resource_key, title: ticket.meta.title, @@ -2330,6 +2348,7 @@ fn ticket_query_item( } } TicketQueryItem { + assignment_tracking: detail.assignment_tracking.clone(), id: summary.id, resource_key: summary.resource_key, title: summary.title, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 859f23587..76578bde6 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -328,31 +328,7 @@ CREATE TABLE server_secret_objects ( REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id) ON DELETE CASCADE ); -CREATE TABLE ticket_assignment_operations ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - assignment_id TEXT, - expected_assignment_id TEXT, - created_at TEXT NOT NULL, - request_fingerprint TEXT, - claim_state TEXT NOT NULL DEFAULT 'committed' - CHECK(claim_state IN ('pending', 'failed', 'committed')), - failure_reason TEXT, - binding_recovery_json TEXT, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK(claim_state = 'committed' OR assignment_id IS NULL), - CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) - ); +-- Ticket-owned assignment schema is composed by store.rs. CREATE TABLE ticket_assignment_ticket_tombstones ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, @@ -368,72 +344,9 @@ CREATE TABLE ticket_assignment_worker_tombstones ( PRIMARY KEY (workspace_id, runtime_id, worker_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); -CREATE TABLE ticket_current_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - assignment_id TEXT NOT NULL, - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), - UNIQUE (workspace_id, assignment_id), - FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) - REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, ticket_id) - REFERENCES typed_tickets(workspace_id, ticket_id) - ON DELETE CASCADE, - CHECK( - (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) - OR - (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) - ) - ); -CREATE TABLE ticket_worker_assignment_events ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - event_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), - assignment_id TEXT, - previous_assignment_id TEXT, - actor TEXT NOT NULL, - created_at TEXT NOT NULL, - operation_id TEXT, - reason TEXT, - PRIMARY KEY (workspace_id, event_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); -CREATE TABLE ticket_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - assigned_by TEXT NOT NULL, - assigned_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, assignment_id), - UNIQUE (workspace_id, ticket_id, assignment_id), - UNIQUE (workspace_id, ticket_id, role, assignment_id), - UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK( - (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) - OR - (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) - ) - ); + + + CREATE TABLE workspace_runtime_bindings ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, @@ -1165,14 +1078,10 @@ CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id, status, credential_id); CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id, hostname, port); -CREATE INDEX idx_ticket_assignment_operations_ticket - ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); -CREATE INDEX idx_ticket_worker_assignment_events_ticket - ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); -CREATE INDEX idx_ticket_worker_assignments_principal - ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); -CREATE INDEX idx_ticket_worker_assignments_ticket - ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); + + + + CREATE INDEX idx_workspace_runtime_bindings_workspace ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); CREATE INDEX idx_typed_ticket_relations_workspace_target @@ -1202,11 +1111,8 @@ CREATE INDEX idx_workspace_resource_keys_reverse ON workspace_resource_keys(workspace_id, resource_kind, resource_key); CREATE INDEX repositories_workspace_provider_idx ON repositories(workspace_id, provider); -CREATE INDEX ticket_current_principal_idx - ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); -CREATE UNIQUE INDEX ticket_current_singleton_role_idx - ON ticket_current_worker_assignments(workspace_id, ticket_id, role) - WHERE role IN ('orchestrator', 'worker'); + + CREATE INDEX typed_ticket_events_workspace_kind_ticket ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d391d9f33..81249d6d7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2805,7 +2805,7 @@ async fn authorize_workspace_api_request( next: axum::middleware::Next, ) -> Response { if !request.uri().path().starts_with("/api/") - && ticket_case_workspace_id(request.uri().path()).is_none() + && canonical_ticket_operation_workspace_id(request.uri().path()).is_none() { return next.run(request).await; } @@ -3028,7 +3028,7 @@ async fn dispatch_workspace_request( if let Some(workspace_id) = workspace_id && (path.starts_with("/api/w/") || path.starts_with("/api/runtime/v1/workspaces/") - || ticket_case_workspace_id(&path).is_some()) + || canonical_ticket_operation_workspace_id(&path).is_some()) && let Err(response) = authorize_scoped_workspace_request(&api, workspace_id, &mut request).await { @@ -3102,9 +3102,9 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } -// The canonical case route is deliberately not an /api/w alias. Recognize only -// this operation so its authentication/scope is identical without broadening CRUD. -fn ticket_case_workspace_id(path: &str) -> Option<&str> { +// Canonical case/tracking 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('/'); let workspace_id = segments.next()?; let resource = segments.next()?; @@ -3113,13 +3113,20 @@ fn ticket_case_workspace_id(path: &str) -> Option<&str> { (!workspace_id.is_empty() && resource == "tickets" && !ticket_id.is_empty() - && operation == "case" - && segments.next().is_none()) + && match operation { + "case" => segments.next().is_none(), + "assignment-tracking" => match segments.next() { + None => true, + Some("history") => segments.next().is_none(), + _ => false, + }, + _ => false, + }) .then_some(workspace_id) } fn scoped_workspace_id(path: &str) -> Option<&str> { - if let Some(workspace_id) = ticket_case_workspace_id(path) { + if let Some(workspace_id) = canonical_ticket_operation_workspace_id(path) { return Some(workspace_id); } if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") { @@ -6229,6 +6236,7 @@ async fn attach_server_request_context( .extensions() .get::() .map(|source| server_api::ServerRuntimeSource { + authentication_binding_id: source.authentication_binding_id.clone(), runtime_id: source.runtime_id.clone(), worker_id: source.worker_id.clone(), }); @@ -6598,6 +6606,11 @@ 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_assignment_tracking_get( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_tracking_update(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_tracking_history(service.clone())) .merge(server_api::server_api_axum::workspace_ticket_tag_list( service.clone(), )) @@ -10535,6 +10548,75 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_assignment_tracking_get( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentTrackingQuery, + ) -> 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)?; + let values = if let Some(assignment_id) = query.assignment_id { + api.store + .get_ticket_assignment_tracking(&workspace_id, &ticket_id, &assignment_id) + .map(|value| value.into_iter().collect::>()) + } else { + api.authority.ticket_assignment_tracking(&ticket_id) + } + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketAssignmentTrackingList(values)) + } + + async fn ticket_assignment_tracking_update( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentTrackingUpdateRequest, + ) -> std::result::Result< + server_api::TicketAssignmentTrackingRecord, + server_api::RepositoryApiError, + > { + let Json(value) = scoped_ticket_assignment_tracking_update_with_context( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, + AxumPath((workspace_id, ticket_id)), + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketAssignmentTrackingRecord(value)) + } + + async fn ticket_assignment_tracking_history( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentTrackingHistoryQuery, + ) -> std::result::Result< + server_api::TicketAssignmentTrackingHistoryPage, + server_api::RepositoryApiError, + > { + let Json(value) = scoped_ticket_assignment_tracking_history( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, ticket_id)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(value) + } + async fn ticket_assignment_list( &self, workspace_id: String, @@ -10554,6 +10636,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_set( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10562,8 +10645,10 @@ impl server_api::ServerApi for ServerApiContractService { server_api::TicketRoleAssignmentMutationResponse, server_api::RepositoryApiError, > { - let Json(response) = scoped_set_ticket_assignment( + let Json(response) = scoped_set_ticket_assignment_with_context( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, AxumPath((workspace_id, id, role)), Json(request), ) @@ -10574,6 +10659,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_clear( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10582,8 +10668,10 @@ impl server_api::ServerApi for ServerApiContractService { server_api::TicketRoleAssignmentMutationResponse, server_api::RepositoryApiError, > { - let Json(response) = scoped_clear_ticket_assignment( + let Json(response) = scoped_clear_ticket_assignment_with_context( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, AxumPath((workspace_id, id, role)), Query(query), ) @@ -13664,10 +13752,85 @@ async fn finalize_manual_ticket_worker_claim( } } +#[cfg(test)] async fn scoped_set_ticket_assignment( + state: State, + path: AxumPath<(String, String, String)>, + request: Json, +) -> ApiResult> { + apply_set_ticket_assignment(state, path, request, "workspace-web".into(), None).await +} + +fn authenticated_assignment_source( + api: &WorkspaceApi, + headers: &HeaderMap, + context: &server_api::ServerRequestContext, +) -> Result<( + String, + crate::store::TicketOrchestratorAssignmentSourceContext, +)> { + if let Some(source) = authenticated_ticket_worker_source(headers, context)? { + if !find_workspace_orchestrator(api).is_some_and(|worker| worker.worker == source) { + return Err(Error::WorkspacePermissionDenied( + "Ticket assignment mutations require the registered current Workspace Orchestrator" + .into(), + )); + } + let authentication_binding_id = context + .runtime_source + .as_ref() + .map(|source| source.authentication_binding_id.clone()) + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Assignment requires the Runtime binding used to authenticate this Worker" + .into(), + ) + })?; + let actor = format!("worker:{}/{}", source.runtime_id, source.worker_id); + return Ok(( + actor, + crate::store::TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: source.runtime_id, + worker_id: source.worker_id, + agent_key: "workspace-orchestrator".into(), + authentication_binding_id, + }, + )); + } + let actor = context.actor.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket assignment mutations require an authenticated actor".into(), + ) + })?; + let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .map_err(|error| Error::Config(format!("failed to encode assignment actor: {error}")))?; + Ok(( + format!("workspace-user:{identity}"), + crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: actor.account_id.clone(), + user_id: actor.user_id.clone(), + }, + )) +} + +async fn scoped_set_ticket_assignment_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + request: Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.0.0)?; + let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + apply_set_ticket_assignment(State(api), path, request, author, Some(source)).await +} + +async fn apply_set_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Json(request): Json, + assigned_by: String, + source: Option, ) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; @@ -13685,7 +13848,7 @@ async fn scoped_set_ticket_assignment( assignment_id: new_id("tasg"), role, principal, - assigned_by: "workspace-web".to_string(), + assigned_by, assigned_at, }; let assignment = match role { @@ -13705,13 +13868,25 @@ async fn scoped_set_ticket_assignment( ) .into()); } - api.store.set_current_ticket_role_assignment( - &record, - expected_assignment_id.as_deref(), - &new_id("tasev"), - &operation_id, - expected_assignment_id.is_some(), - )? + if let Some(source) = source.as_ref() { + api.store.set_current_ticket_orchestrator_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + source, + )? + } else { + // Only the cfg(test) direct-handler adapter lacks a Backend source. + api.store.set_current_ticket_role_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + )? + } } TicketAssignmentRole::Worker => { if expected_assignment_id.is_some() { @@ -13744,10 +13919,33 @@ async fn scoped_set_ticket_assignment( })) } +#[cfg(test)] async fn scoped_clear_ticket_assignment( + state: State, + path: AxumPath<(String, String, String)>, + query: Query, +) -> ApiResult> { + apply_clear_ticket_assignment(state, path, query, "workspace-web".into(), None).await +} + +async fn scoped_clear_ticket_assignment_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + query: Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.0.0)?; + let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + apply_clear_ticket_assignment(State(api), path, query, author, Some(source)).await +} + +async fn apply_clear_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Query(query): Query, + author: String, + source: Option, ) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; @@ -13766,17 +13964,36 @@ async fn scoped_clear_ticket_assignment( .ok_or_else(|| { Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) })?; - let cleared = api.store.clear_current_ticket_role_assignment( - &workspace_id, - &ticket.id, - role, - &assignment_id, - &new_id("tasev"), - &operation_id, - "workspace-web", - &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - Some("role assignment removed from Ticket detail"), - )?; + let event_id = new_id("tasev"); + let occurred_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let reason = Some("role assignment removed from Ticket detail"); + let cleared = if role == TicketAssignmentRole::Orchestrator + && let Some(source) = source.as_ref() + { + api.store.clear_current_ticket_orchestrator_assignment( + &workspace_id, + &ticket.id, + &assignment_id, + &event_id, + &operation_id, + &author, + &occurred_at, + reason, + source, + )? + } else { + api.store.clear_current_ticket_role_assignment( + &workspace_id, + &ticket.id, + role, + &assignment_id, + &event_id, + &operation_id, + &author, + &occurred_at, + reason, + )? + }; if !cleared { return Err(Error::TicketAssignmentConflict(format!( "assignment `{assignment_id}` is not current for role `{}`", @@ -16509,6 +16726,82 @@ async fn scoped_ticket_case_update( apply_ticket_case_update(State(api), source, None, path, request).await } +async fn scoped_ticket_assignment_tracking_update_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(update): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + let source = authenticated_ticket_worker_source(&headers, context)?.ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Tracking is self-reported by a verified Runtime Worker".into(), + ) + })?; + // Tracking authority is retained responsibility plus the registered singleton, + // NOT Ticket openness, active work, Worker state or a model-supplied role. + if !find_workspace_orchestrator(&api).is_some_and(|worker| worker.worker == source) { + return Err(Error::WorkspacePermissionDenied( + "Tracking updates require the registered current Workspace Orchestrator".into(), + ) + .into()); + } + let authentication_binding_id = context + .runtime_source + .as_ref() + .map(|source| source.authentication_binding_id.clone()) + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Tracking requires the Runtime binding actually used to authenticate the request" + .into(), + ) + })?; + let source_context = ticket::TicketAssignmentTrackingSourceContext { + actor: format!("worker:{}/{}", source.runtime_id, source.worker_id), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + agent_key: "workspace-orchestrator".into(), + authentication_binding_id, + }; + // The Ticket-owned mutation checks current assignment/CAS and exact replay in + // one transaction. A historical receipt never restores operational grants. + let value = api.store.update_ticket_assignment_tracking( + &workspace_id, + &ticket_id, + &update, + &source_context, + )?; + Ok(Json(value)) +} + +async fn scoped_ticket_assignment_tracking_history( + State(api): State, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + let limit = query.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err( + Error::InvalidInput("tracking history limit must be between 1 and 100".into()).into(), + ); + } + let page = api.store.page_ticket_assignment_tracking_history( + &workspace_id, + &ticket_id, + &query.assignment_id, + query.after_event_id.as_deref(), + limit, + )?; + Ok(Json(server_api::TicketAssignmentTrackingHistoryPage { + items: page.items, + next_event_id: page.next_event_id, + })) +} + async fn scoped_ticket_case_update_with_context( State(api): State, headers: HeaderMap, @@ -16516,6 +16809,20 @@ async fn scoped_ticket_case_update_with_context( path: AxumPath<(String, String)>, request: Json, ) -> ApiResult> { + let source = authenticated_ticket_worker_source(&headers, context)?; + if source.is_none() && context.actor.is_none() { + return Err(Error::WorkspacePermissionDenied( + "Ticket case decisions require an authenticated actor".into(), + ) + .into()); + } + apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await +} + +fn authenticated_ticket_worker_source( + headers: &HeaderMap, + context: &server_api::ServerRequestContext, +) -> Result> { // Authentication already verified membership and the request's signature/body. // Hints are redundant assertions, never authority or a required identity source. let source = if let Some(source) = context.runtime_source.as_ref() { @@ -16551,13 +16858,7 @@ async fn scoped_ticket_case_update_with_context( .into()); } } - if source.is_none() && context.actor.is_none() { - return Err(Error::WorkspacePermissionDenied( - "Ticket case decisions require an authenticated actor".into(), - ) - .into()); - } - apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await + Ok(source) } async fn apply_ticket_case_update( @@ -28087,6 +28388,7 @@ async fn runtime_resource_fetch_contract( message: "Runtime request proof is invalid".to_owned(), })?; server_api::ServerRuntimeSource { + authentication_binding_id: source.authentication_binding_id, runtime_id: source.runtime_id, worker_id: source.worker_id, } @@ -32488,6 +32790,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod assignment_tracking_tests { + include!("server_assignment_tracking_tests.rs"); + } + mod ticket_case_signed_tests { include!("server_ticket_case_signed_tests.rs"); } @@ -37935,6 +38241,7 @@ mod tests { .uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers")) // Unit fixture stands below proof middleware, so inject its verified subject. .extension(crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), token_id_hash: None, runtime_id: fixture.controller.runtime_id.clone(), worker_id: Some(fixture.controller.worker_id.clone()), @@ -38259,6 +38566,7 @@ mod tests { .method("POST") .uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers")) .extension(crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), token_id_hash: None, runtime_id: controller.runtime_id.clone(), worker_id: Some(controller.worker_id.clone()), @@ -40293,6 +40601,7 @@ mod tests { actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: created.runtime_id.clone(), worker_id: Some(created.worker_id.clone()), }), @@ -52922,6 +53231,7 @@ mod tests { worker_id: "legacy-worker".to_string(), }), Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-source".to_string(), worker_id: Some("runtime-worker".to_string()), }), @@ -52934,6 +53244,7 @@ mod tests { contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-source".to_string(), worker_id: Some("runtime-worker".to_string()), }), @@ -52946,6 +53257,7 @@ mod tests { contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-only".to_string(), worker_id: None, }), @@ -52957,6 +53269,7 @@ mod tests { let error = contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-only".to_string(), worker_id: None, }), @@ -61161,6 +61474,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: dedicated.worker.runtime_id.clone(), worker_id: Some(dedicated.worker.worker_id.clone()), }), @@ -61184,6 +61498,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: forged.worker.runtime_id.clone(), worker_id: Some(forged.worker.worker_id.clone()), }), diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index 777219c7d..0d89bd926 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -721,6 +721,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay( token_id_hash: None, runtime_id: workers[0].runtime_id.clone(), worker_id: Some(workers[0].worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }, )); let response = app @@ -867,6 +868,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse context.runtime_source = Some(ServerRuntimeSource { runtime_id: workers[0].runtime_id.clone(), worker_id: Some(workers[1].worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }); assert_eq!( drive::root(&api, &context, TEST_WORKSPACE_ID) diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index adc0d79ab..3e1ff2ddf 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -53,6 +53,7 @@ fn context(worker: &RuntimeWorkerRef) -> server_api::ServerRequestContext { runtime_source: Some(server_api::ServerRuntimeSource { runtime_id: worker.runtime_id.clone(), worker_id: Some(worker.worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }), origin: None, transport_headers: vec![], diff --git a/crates/workspace-server/src/server/tests/worker_operations_tests.rs b/crates/workspace-server/src/server/tests/worker_operations_tests.rs index 80bfe89bc..2229bdb59 100644 --- a/crates/workspace-server/src/server/tests/worker_operations_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_operations_tests.rs @@ -632,12 +632,14 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback() assert!(WorkerOperationContext::from_request(&api, &headers_only).is_err()); let mut runtime_only = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: None, })); runtime_only.actor = Some(test_browser_request_actor()); assert!(WorkerOperationContext::from_request(&api, &runtime_only).is_err()); let mut mismatched = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller-a".into()), })); mismatched.actor = Some(test_browser_request_actor()); @@ -646,6 +648,7 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback() seed_worker_source_member(&api, "runtime-a", "controller-a"); let context = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller-a".into()), })); assert!( @@ -665,6 +668,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_ let context = || { operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "offline-runtime".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller".into()), })) }; diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs new file mode 100644 index 000000000..69cc88c88 --- /dev/null +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -0,0 +1,1344 @@ +use super::*; + +fn tracking_path(ticket: &str) -> String { + format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-tracking") +} + +fn assignment_request( + identity: &RuntimeIdentityMaterial, + worker: &str, + ticket: &str, + operation: &str, +) -> Request { + runtime_source_request(identity, Some(worker), "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()) +} + +fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> String { + format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={assignment}&operation_id={operation}" + ) +} + +fn replace_tracking_orchestrator( + api: &WorkspaceApi, + runtime: &WorkdirlessFixtureRuntime, + source: &RuntimeWorkerRef, +) -> RuntimeWorkerRef { + let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(api, &replacement.runtime_id, &replacement.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = replacement.clone(); + runtime.workers.lock().unwrap().push(summary); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + assert_eq!( + find_workspace_orchestrator(api).unwrap().worker, + replacement + ); + replacement +} + +#[tokio::test] +async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + let clear_path = + assignment_clear_path(&ticket, ¤t.assignment_id, "clear-before-revocation"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2026-10-11T00:00:00Z' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + for request in [ + assignment_request(&identity, &source.worker_id, &ticket, "assign-tracking"), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + signed_tracking_update( + &identity, + Some(&source.worker_id), + &ticket, + &tracking_update(¤t, "revoked-report"), + ), + ] { + tracking_response(app.clone(), request, StatusCode::UNAUTHORIZED).await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + released + ); +} + +#[tokio::test] +async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_old_receipts() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + let clear_path = + assignment_clear_path(&ticket, ¤t.assignment_id, "clear-before-replacement"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let replacement = replace_tracking_orchestrator(&api, &runtime, &source); + for worker in [&source.worker_id, &replacement.worker_id] { + let expected = if worker == &source.worker_id { + StatusCode::UNAUTHORIZED + } else { + StatusCode::CONFLICT + }; + tracking_response( + app.clone(), + assignment_request(&identity, worker, &ticket, "assign-tracking"), + expected, + ) + .await; + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(worker), "DELETE", &clear_path, Vec::new()), + expected, + ) + .await; + } + assert!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); + tracking_response( + app, + assignment_request( + &identity, + &replacement.worker_id, + &ticket, + "replacement-new-request", + ), + StatusCode::OK, + ) + .await; + let next = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(); + assert_eq!(next.len(), 1); + assert_ne!(next[0].assignment_id, current.assignment_id); +} + +#[tokio::test] +async fn runtime_orchestrator_tags_without_backend_singleton_do_not_grant_assignment_authority() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let forged = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(&api, &forged.runtime_id, &forged.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = forged.clone(); + summary.singleton_key = Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()); + runtime.workers.lock().unwrap().push(summary); + let app = build_router(api.clone()); + for request in [ + assignment_request(&identity, &forged.worker_id, &ticket, "forged-assign"), + runtime_source_request( + &identity, + Some(&forged.worker_id), + "DELETE", + &assignment_clear_path(&ticket, ¤t.assignment_id, "forged-clear"), + Vec::new(), + ), + signed_tracking_update( + &identity, + Some(&forged.worker_id), + &ticket, + &tracking_update(¤t, "forged-tracking"), + ), + ] { + tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await; + } + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reject_other_accounts() +{ + let dir = tempfile::tempdir().unwrap(); + let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner"); + let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap(); + let request = |token: &str, method: &str, path: &str, body: Vec| { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap() + }; + let app = build_router(api.clone()); + let assigned = tracking_response( + app.clone(), + request(&owner, "PUT", &path, body.clone()), + StatusCode::OK, + ) + .await; + let actor = format!( + "workspace-user:{}", + serde_json::to_string(&( + "account-assignment-account-owner", + "user-assignment-account-owner" + )) + .unwrap() + ); + assert_eq!(assigned["assignment"]["assigned_by"], actor); + assert_eq!( + tracking_response( + app.clone(), + request(&owner, "PUT", &path, body.clone()), + StatusCode::OK + ) + .await, + assigned + ); + tracking_response( + app.clone(), + request(&other, "PUT", &path, body), + StatusCode::CONFLICT, + ) + .await; + let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let clear_path = assignment_clear_path(&ticket, assignment_id, "account-clear"); + let cleared = tracking_response( + app.clone(), + request(&owner, "DELETE", &clear_path, Vec::new()), + StatusCode::OK, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + request(&owner, "DELETE", &clear_path, Vec::new()), + StatusCode::OK + ) + .await, + cleared + ); + tracking_response( + app, + request(&other, "DELETE", &clear_path, Vec::new()), + StatusCode::CONFLICT, + ) + .await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment_id) + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.assigned_by, actor); + assert_eq!(released.release.unwrap().actor, actor); +} + +async fn assign_tracking( + api: &WorkspaceApi, + identity: &RuntimeIdentityMaterial, + source: &RuntimeWorkerRef, + ticket: &str, +) -> ticket::TicketAssignmentTrackingProjection { + let value = tracking_response(build_router(api.clone()), runtime_source_request( + identity, Some(&source.worker_id), "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } })).unwrap(), + ), StatusCode::OK).await; + assert_eq!( + value["assignment"]["assigned_by"], + format!("worker:{}/{}", source.runtime_id, source.worker_id) + ); + let current = tracking_response( + build_router(api.clone()), + runtime_source_request( + identity, + Some(&source.worker_id), + "GET", + &tracking_path(ticket), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(current.as_array().unwrap().len(), 1); + serde_json::from_value(current[0].clone()).unwrap() +} + +fn tracking_update( + current: &ticket::TicketAssignmentTrackingProjection, + key: &str, +) -> ticket::TicketAssignmentTrackingUpdate { + ticket::TicketAssignmentTrackingUpdate { + assignment_id: current.assignment_id.clone(), + expected_last_event_id: current.last_event_id.clone().unwrap(), + operation_key: key.into(), + tracking: ticket::TicketAssignmentTracking { + status: ticket::TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: Some("Investigated".into()), + next_action: Some("Return to human".into()), + result: Some("Result recorded without closing the case".into()), + }, + } +} + +fn signed_tracking_update( + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + ticket: &str, + update: &ticket::TicketAssignmentTrackingUpdate, +) -> Request { + runtime_source_request( + identity, + worker, + "POST", + &tracking_path(ticket), + serde_json::to_vec(update).unwrap(), + ) +} + +#[tokio::test] +async fn signed_current_orchestrator_without_hints_records_done_without_closing_or_releasing_work() +{ + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + assert_eq!( + current.tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(); + let update = tracking_update(¤t, "report-done"); + let response = tracking_response( + build_router(api.clone()), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + assert_eq!(response["tracking"]["status"], "done"); + assert_ne!(response["last_event_id"], current.last_event_id.unwrap()); + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(), + before + ); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_some() + ); + let detail = api.authority.ticket(&ticket).unwrap(); + assert_eq!( + detail.assignment_tracking[0] + .tracking + .as_ref() + .unwrap() + .status, + ticket::TicketAssignmentTrackingStatus::Done + ); + let history = tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}/history?assignment_id={}", + tracking_path(&ticket), + update.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let event = history["items"].as_array().unwrap().last().unwrap(); + assert_eq!(event["source_context"]["runtime_id"], source.runtime_id); + assert_eq!(event["source_context"]["worker_id"], source.worker_id); + assert_eq!( + event["source_context"]["authentication_binding_id"], + api.config_store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, &source.runtime_id) + .unwrap() + .unwrap() + .binding_id + ); +} + +#[tokio::test] +async fn tracking_runtime_only_and_non_orchestrator_and_forged_source_hints_are_denied() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + seed_worker_source_member(&api, &source.runtime_id, "other"); + let update = tracking_update(¤t, "unauthorized"); + let app = build_router(api.clone()); + for worker in [None, Some("other")] { + tracking_response( + app.clone(), + signed_tracking_update(&identity, worker, &ticket, &update), + StatusCode::FORBIDDEN, + ) + .await; + } + for (runtime_hint, worker_hint) in [ + (Some(source.runtime_id.as_str()), Some("other")), + (Some("another-runtime"), Some(source.worker_id.as_str())), + (None, Some(source.worker_id.as_str())), + ] { + let mut request = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + if let Some(hint) = runtime_hint { + request + .headers_mut() + .insert("x-yoi-runtime-id", hint.parse().unwrap()); + } + if let Some(hint) = worker_hint { + request + .headers_mut() + .insert("x-yoi-worker-id", hint.parse().unwrap()); + } + tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn tracking_closed_ticket_current_orchestrator_can_record_result_without_restoring_grants() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let backend = browser_ticket_backend(&api).unwrap(); + let before = backend.show(ticket.clone().into()).unwrap(); + let close = case_test_request( + &before, + "close-tracking", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/case"), + serde_json::to_vec(&serde_json::json!({ + "expected_content_digest": close.expected_content_digest, + "expected_state": close.expected_state, + "expected_closure_operation_key": close.expected_closure_operation_key, + "operation_key": close.operation_key, + "action": close.action, + "explanation": close.explanation, + "references": close.references, + })) + .unwrap(), + ), + StatusCode::OK, + ) + .await; + let closed = backend.show(ticket.clone().into()).unwrap(); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + let update = tracking_update(¤t, "post-close-result"); + tracking_response( + build_router(api.clone()), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + assert_eq!(backend.show(ticket.clone().into()).unwrap(), closed); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + assert_eq!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .unwrap() + .assignment_id, + current.assignment_id + ); +} + +async fn tracking_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap_or_else(|_| { + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "non-JSON API response: {}", + String::from_utf8_lossy(&body) + ); + serde_json::Value::String(String::from_utf8(body.to_vec()).unwrap()) + }) + } +} + +async fn tracking_fixture( + root: &Path, +) -> ( + WorkspaceApi, + WorkdirlessFixtureRuntime, + RuntimeIdentityMaterial, + RuntimeWorkerRef, + String, +) { + let mut api = test_api(root).await; + let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID; + register_test_runtime(&api, runtime_id).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, runtime_id); + let memory = api + .config_store + .get_workspace_memory_settings(TEST_WORKSPACE_ID) + .unwrap(); + let reservation = api + .config_store + .reserve_worker_create( + TEST_WORKSPACE_ID, + runtime_id, + "tracking-orchestrator", + "sha256:tracking-orchestrator", + Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY), + &memory, + ) + .unwrap(); + let mut summary = WorkdirlessFixtureRuntime::worker_summary( + WorkerCreateBinding { + worker_id: reservation.worker_id, + create_fingerprint: reservation.create_fingerprint, + }, + &WorkerSpawnRequest { + requested_worker_name: Some("Tracking Orchestrator".into()), + singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()), + intent: WorkerSpawnIntent::WorkspaceCompanion, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: ProfileSelector::Builtin("builtin:orchestrator".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_control_operation: None, + }, + ); + summary.tags = vec!["orchestrator".into(), "singleton".into()]; + let source = summary.worker.clone(); + runtime.workers.lock().unwrap().push(summary); + seed_worker_source_member(&api, runtime_id, &source.worker_id); + api.config_store + .complete_worker_create_reservation(TEST_WORKSPACE_ID, source.worker_id.parse().unwrap()) + .unwrap(); + assert_eq!(find_workspace_orchestrator(&api).unwrap().worker, source); + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Tracking authority")) + .unwrap(); + (api, runtime, identity, source, ticket.id) +} + +#[tokio::test] +async fn tracking_exact_replay_is_source_bound_and_stale_event_cas_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "replay-key"); + let app = build_router(api.clone()); + let first_request = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + let proof = + first_request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let result = tracking_response(app.clone(), first_request, StatusCode::OK).await; + let mut same_proof = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + same_proof.headers_mut().insert( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ); + tracking_response(app.clone(), same_proof, StatusCode::UNAUTHORIZED).await; + assert_eq!( + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK + ) + .await, + result + ); + let mut changed = update.clone(); + changed.tracking.result = Some("Changed result".into()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &changed), + StatusCode::CONFLICT, + ) + .await; + let stale = tracking_update(¤t, "stale-last-event"); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + seed_worker_source_member(&api, &source.runtime_id, "other"); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some("other"), &ticket, &update), + StatusCode::FORBIDDEN, + ) + .await; + let history = api + .store + .list_ticket_assignment_tracking_history(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap(); + assert_eq!( + history.len(), + 2, + "Assign plus one committed tracking update only" + ); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap() + .tracking + .unwrap(), + update.tracking + ); +} + +#[tokio::test] +async fn tracking_unassign_and_reassign_keep_results_and_fence_old_assignment() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "before-reassign"); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "DELETE", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=release-tracking", current.assignment_id), Vec::new()), StatusCode::OK).await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.tracking.unwrap(), update.tracking); + let mut stale = update.clone(); + stale.operation_key = "released-update".into(); + stale.expected_last_event_id = result["last_event_id"].as_str().unwrap().into(); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK + ) + .await, + result + ); + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await; + let next = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(); + assert_ne!(next[0].assignment_id, current.assignment_id); + assert_eq!( + next[0].tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + tracking_response( + app, + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_ticket_assignment_tracking_history( + TEST_WORKSPACE_ID, + &ticket, + ¤t.assignment_id + ) + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn tracking_other_workspace_routes_and_unsigned_reads_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "foreign-workspace"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + Request::builder() + .uri(tracking_path(&ticket)) + .body(Body::empty()) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let path = format!("/workspaces/another-workspace/tickets/{ticket}/assignment-tracking"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&update).unwrap(), + ), + StatusCode::NOT_FOUND, + ) + .await; + for path in [ + path.clone(), + format!("{path}/history?assignment_id={}", current.assignment_id), + ] { + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::NOT_FOUND, + ) + .await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_inherits_tracking() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "old-orchestrator-result"); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = replacement.clone(); + runtime.workers.lock().unwrap().push(summary); + // Seed the authoritative replacement, not merely a Runtime tag or model role. + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + assert_eq!( + find_workspace_orchestrator(&api).unwrap().worker, + replacement + ); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut new_update = update.clone(); + new_update.operation_key = "new-orchestrator-result".into(); + new_update.expected_last_event_id = result["last_event_id"].as_str().unwrap().into(); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &new_update), + StatusCode::UNAUTHORIZED, + ) + .await; + // A different authenticated current actor cannot reuse the old actor's receipt. + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&replacement.worker_id), &ticket, &update), + StatusCode::CONFLICT, + ) + .await; + let inherited = tracking_response( + app, + signed_tracking_update( + &identity, + Some(&replacement.worker_id), + &ticket, + &new_update, + ), + StatusCode::OK, + ) + .await; + assert_eq!(inherited["assignment_id"], current.assignment_id); + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn tracking_changed_runtime_binding_cannot_replay_original_receipt() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "binding-replay"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='replacement-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + tracking_response( + app, + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_ticket_assignment_tracking_history( + TEST_WORKSPACE_ID, + &ticket, + ¤t.assignment_id + ) + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_replay_it() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let replay = assign_tracking(&api, &identity, &source, &ticket).await; + assert_eq!(replay, current); + let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap(); + tracking_response( + build_router(api.clone()), + Request::builder() + .method("PUT") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap(), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn tracking_history_is_assignment_scoped_and_paged_and_rejects_unknown_cursors() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "history-done"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let path = format!( + "{}/history?assignment_id={}&limit=1", + tracking_path(&ticket), + current.assignment_id + ); + let first = tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::OK, + ) + .await; + assert_eq!(first["items"].as_array().unwrap().len(), 1); + assert_eq!(first["items"][0]["tracking"]["status"], "waiting"); + let second = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{path}&after_event_id={}", + first["next_event_id"].as_str().unwrap() + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(second["items"].as_array().unwrap().len(), 1); + assert_eq!(second["items"][0]["tracking"]["status"], "done"); + assert!(second["next_event_id"].is_null()); + for suffix in [ + "&after_event_id=another-assignment-event", + "&limit=0", + "&limit=101", + ] { + let path = if suffix.starts_with("&limit=") { + format!( + "{}/history?assignment_id={}{}", + tracking_path(&ticket), + current.assignment_id, + suffix + ) + } else { + format!("{path}{suffix}") + }; + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::BAD_REQUEST, + ) + .await; + } +} + +#[tokio::test] +async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not_self_reporter() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "claimed-role"); + let app = build_router(api.clone()); + for (key, value) in [ + ("author", serde_json::json!("orchestrator")), + ("role", serde_json::json!("orchestrator")), + ( + "source_context", + serde_json::json!({"worker_id":source.worker_id,"runtime_id":source.runtime_id}), + ), + ] { + let mut input = serde_json::to_value(&update).unwrap(); + input[key] = value; + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &tracking_path(&ticket), + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + } + let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent"); + tracking_response( + app, + Request::builder() + .method("POST") + .uri(tracking_path(&ticket)) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn assignment_receipt_cannot_be_replayed_under_a_new_runtime_binding() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-assignment-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap(); + tracking_response( + build_router(api.clone()), + runtime_source_request(&identity, Some(&source.worker_id), "PUT", &path, body), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=source-bound-clear", + current.assignment_id + ); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new() + ), + StatusCode::OK + ) + .await, + result + ); + let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor"); + tracking_response( + app.clone(), + Request::builder() + .method("DELETE") + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + StatusCode::CONFLICT, + ) + .await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-clear-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new(), + ), + StatusCode::CONFLICT, + ) + .await; + assert!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn released_assignment_query_retains_principal_assign_audit_and_last_tracking_without_becoming_current() + { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "DELETE", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=release-snapshot", current.assignment_id), Vec::new()), StatusCode::OK).await; + let values = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&ticket), + current.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(values.as_array().unwrap().len(), 1); + let released = &values[0]; + assert_eq!(released["assignment_id"], current.assignment_id); + assert_eq!(released["role"], "orchestrator"); + assert_eq!( + released["principal"], + serde_json::json!({"kind":"workspace_agent","agent_key":"workspace-orchestrator"}) + ); + assert_eq!(released["assigned_by"], current.assigned_by); + assert_eq!(released["assigned_at"], current.assigned_at); + assert_eq!(released["current"], false); + assert_eq!(released["tracking"]["status"], "waiting"); + assert_eq!(released["release"]["action"], "unassigned"); + assert_eq!(released["release"]["operation_key"], "release-snapshot"); + assert_eq!(released["release"]["actor"], current.assigned_by); + assert_eq!(released["release"]["last_tracking"]["status"], "waiting"); + assert!(released["release"]["replacement_assignment_id"].is_null()); + assert_eq!( + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &tracking_path(&ticket), + Vec::new() + ), + StatusCode::OK + ) + .await, + serde_json::json!([]) + ); + let other_ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Different Ticket")) + .unwrap(); + assert_eq!( + tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&other_ticket.id), + current.assignment_id + ), + Vec::new() + ), + StatusCode::OK + ) + .await, + serde_json::json!([]) + ); +} + +#[tokio::test] +async fn same_principal_reassignment_retains_old_result_and_projects_replacement_identity() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "done-before-new-request"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await; + let next_id = assignment["assignment"]["assignment_id"].as_str().unwrap(); + assert_ne!(next_id, current.assignment_id); + let old = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&ticket), + current.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(old[0]["current"], false); + assert_eq!(old[0]["tracking"]["status"], "done"); + assert_eq!( + old[0]["tracking"]["result"], + update.tracking.result.unwrap() + ); + assert_eq!(old[0]["release"]["action"], "replaced"); + assert_eq!(old[0]["release"]["replacement_assignment_id"], next_id); + assert_eq!(old[0]["release"]["operation_key"], "request-again"); + let next = tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &tracking_path(&ticket), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(next.as_array().unwrap().len(), 1); + assert_eq!(next[0]["assignment_id"], next_id); + assert_eq!(next[0]["tracking"]["status"], "waiting"); + assert!(next[0]["tracking"]["result"].is_null()); + assert!(next[0]["release"].is_null()); +} + +#[tokio::test] +async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_role() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, _, ticket) = tracking_fixture(dir.path()).await; + let input = serde_json::json!({ + "operation_id": "runtime-only-assign", + "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } + }); + tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + None, + "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::FORBIDDEN, + ) + .await; + assert!( + api.store + .list_current_ticket_role_assignments(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 013b0a4e2..458a860b3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,8 +27,17 @@ use crate::backend_job::{ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; +#[path = "assignment_tracking.rs"] +mod assignment_tracking; +pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext; +pub use ticket::{ + TicketAssignmentTracking, TicketAssignmentTrackingHistoryEntry, + TicketAssignmentTrackingProjection, TicketAssignmentTrackingSourceContext, + TicketAssignmentTrackingStatus, TicketAssignmentTrackingUpdate, +}; + const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 91; +const LATEST_SCHEMA_VERSION: i64 = 92; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -291,6 +300,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Ticket open/closed cases and retained routing evidence", apply: migrate_ticket_cases_v90_to_v91, }, + Migration { + version: 92, + name: "Ticket-owned assignment tracking", + apply: migrate_ticket_tracking_v91_to_v92, + }, ]; #[derive(Clone, Copy)] @@ -1821,6 +1835,38 @@ 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 update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result; + fn get_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result>; + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result>; + fn page_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, + ) -> Result; + fn list_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result>; fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -1837,6 +1883,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ticket_id: &str, role: TicketAssignmentRole, ) -> Result>; + fn set_current_ticket_orchestrator_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result; + fn clear_current_ticket_orchestrator_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result; fn set_current_ticket_role_assignment( &self, record: &TicketRoleAssignmentRecord, @@ -7685,23 +7752,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { transaction.commit()?; return Ok(()); // Pending/failed fingerprints are durable receipts. } - ensure_no_competing_ticket_claim(&transaction, workspace_id, &operation.ticket_id, operation.worker.as_ref(), operation_id)?; - transaction.execute( - "DELETE FROM ticket_assignment_operations WHERE workspace_id = ?1 AND operation_id = ?2", - params![workspace_id, operation_id], - )?; - if let Some(assignment_id) = operation.assignment_id { - transaction.execute( - "DELETE FROM ticket_worker_assignment_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND assignment_id = ?3", - params![workspace_id, operation.ticket_id, assignment_id], - )?; - transaction.execute( - "DELETE FROM ticket_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND assignment_id = ?3", - params![workspace_id, operation.ticket_id, assignment_id], - )?; - } - transaction.commit()?; - Ok(()) + Err(Error::TicketAssignmentConflict(format!("Ticket operation {operation_id} is committed; use a new explicit unassign operation instead of deleting immutable assignment evidence"))) }) } @@ -7768,6 +7819,55 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result { + assignment_tracking::update(self, workspace_id, ticket_id, update, source) + } + fn get_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result> { + assignment_tracking::get(self, workspace_id, ticket_id, assignment_id) + } + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { + assignment_tracking::current(self, workspace_id, ticket_id) + } + fn page_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_tracking::history_page( + self, + workspace_id, + ticket_id, + assignment_id, + after_event_id, + limit, + ) + } + fn list_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result> { + assignment_tracking::history(self, workspace_id, ticket_id, assignment_id) + } fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -7839,216 +7939,65 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, allow_reassign: bool, ) -> Result { - validate_ticket_assignment_role_principal(record.role, &record.principal)?; - let principal_json = serde_json::to_string(&record.principal).map_err(|error| { - Error::Store(format!("serialize Ticket assignment principal: {error}")) - })?; - let mut hasher = Sha256::new(); - hasher.update(b"ticket-role-assignment:set:v1\0"); - for value in [ - record.workspace_id.as_str(), - record.ticket_id.as_str(), - record.role.as_str(), - principal_json.as_str(), - record.assigned_by.as_str(), - expected_assignment_id.unwrap_or(""), - if allow_reassign { "reassign" } else { "assign" }, - ] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - let request_fingerprint = hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let (principal_id, runtime_id, worker_id) = match &record.principal { - TicketAssignmentPrincipal::User { account_id } => { - (Some(account_id.as_str()), None, None) - } - TicketAssignmentPrincipal::Worker { - runtime_id, - worker_id, - } => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())), - TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { - (Some(agent_key.as_str()), None, None) - } - }; + self.set_ticket_role_assignment_with_source( + record, + expected_assignment_id, + event_id, + operation_id, + allow_reassign, + None, + ) + } + + fn set_current_ticket_orchestrator_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result { + self.set_ticket_role_assignment_with_source( + record, + expected_assignment_id, + event_id, + operation_id, + allow_reassign, + Some(source), + ) + } + fn clear_current_ticket_orchestrator_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - if let Some(worker) = record.principal.worker() { - ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; - } - - let existing_operation: Option<(String, Option)> = tx - .query_row( - "SELECT request_fingerprint, assignment_id - FROM ticket_assignment_operations - WHERE workspace_id = ?1 AND operation_id = ?2", - params![record.workspace_id, operation_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((persisted_fingerprint, assignment_id)) = existing_operation { - if persisted_fingerprint != request_fingerprint { - return Err(Error::TicketAssignmentConflict(format!( - "operation `{operation_id}` was already used for different Ticket assignment input" - ))); - } - let assignment_id = assignment_id.ok_or_else(|| { - Error::Store(format!( - "Ticket role assignment operation `{operation_id}` is missing assignment identity" - )) - })?; - let persisted = read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict(format!( - "assignment `{assignment_id}` recorded by operation `{operation_id}` no longer exists" - )) - })?; - tx.commit()?; - return Ok(persisted); - } - - ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, - record.principal.worker().as_ref(), operation_id)?; - let current_sql = ticket_role_assignment_select_sql( - "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ - ORDER BY a.assigned_at, a.assignment_id", - ); - let mut current_stmt = tx.prepare(¤t_sql)?; - let current = current_stmt - .query_map( - params![record.workspace_id, record.ticket_id, record.role.as_str()], - read_ticket_role_assignment_record, - )? - .collect::>>()?; - drop(current_stmt); - - let replaced = if let Some(expected_assignment_id) = expected_assignment_id { - let Some(expected) = current - .iter() - .find(|assignment| assignment.assignment_id == expected_assignment_id) - else { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{}` role `{}` expected assignment `{expected_assignment_id}`, current is {:?}", - record.ticket_id, - record.role.as_str(), - current.first().map(|value| value.assignment_id.as_str()) - ))); - }; - if !allow_reassign { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{}` role `{}` is already assigned as `{}`", - record.ticket_id, - record.role.as_str(), - expected.assignment_id - ))); - } - Some(expected.clone()) - } else if record.role.is_singleton() && !current.is_empty() { - let active: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)", - params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?; - if active { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} already has unfinished {} work; use explicit reassign", - record.ticket_id, record.role.as_str()))); - } - Some(current[0].clone()) - } else { - None - }; - - tx.execute( - "INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, role, principal_kind, - principal_id, runtime_id, worker_id, assignment_id, - expected_assignment_id, created_at, request_fingerprint - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - record.workspace_id, - operation_id, - if replaced.is_some() { "reassign" } else { "assign" }, - record.ticket_id, - record.role.as_str(), - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assignment_id, - expected_assignment_id, - record.assigned_at, - request_fingerprint, - ], + assignment_tracking::require_assignment_source(&tx, workspace_id, source)?; + let result = clear_current_ticket_role_assignment_with_source_in_tx( + &tx, + workspace_id, + ticket_id, + TicketAssignmentRole::Orchestrator, + assignment_id, + event_id, + operation_id, + actor, + occurred_at, + reason, + "ticket-orchestrator-assignment:clear:v2", + Some(source), )?; - tx.execute( - "INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, role, principal_kind, - principal_id, runtime_id, worker_id, assigned_by, assigned_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - record.workspace_id, - record.ticket_id, - record.assignment_id, - record.role.as_str(), - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assigned_by, - record.assigned_at, - ], - )?; - if let Some(previous) = &replaced { - tx.execute( - "DELETE FROM ticket_current_worker_assignments - WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - previous.assignment_id, - ], - )?; - } - tx.execute( - "INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, role, assignment_id, principal_kind, - principal_id, runtime_id, worker_id, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - record.assignment_id, - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assigned_at, - ], - )?; - tx.execute( - "INSERT INTO ticket_worker_assignment_events ( - workspace_id, ticket_id, role, event_id, action, assignment_id, - previous_assignment_id, actor, created_at, operation_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - event_id, - if replaced.is_some() { "reassigned" } else { "assigned" }, - record.assignment_id, - replaced.as_ref().map(|value| value.assignment_id.as_str()), - record.assigned_by, - record.assigned_at, - operation_id, - ], - )?; - tx.commit()?; - Ok(record.clone()) + tx.commit()?; + Ok(result) }) } @@ -11410,6 +11359,246 @@ fn ensure_worker_assignment_available( Ok(()) } +impl SqliteWorkspaceStore { + fn set_ticket_role_assignment_with_source( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: Option<&TicketOrchestratorAssignmentSourceContext>, + ) -> Result { + if source.is_some() && record.role != TicketAssignmentRole::Orchestrator { + return Err(Error::TicketAssignmentConflict( + "source-aware assignment requires the Orchestrator role".into(), + )); + } + let source_json = source + .map(serde_json::to_string) + .transpose() + .map_err(|error| Error::Store(error.to_string()))?; + validate_ticket_assignment_role_principal(record.role, &record.principal)?; + let principal_json = serde_json::to_string(&record.principal).map_err(|error| { + Error::Store(format!("serialize Ticket assignment principal: {error}")) + })?; + let mut hasher = Sha256::new(); + if let Some(source_json) = &source_json { + hasher.update(b"ticket-orchestrator-assignment:set:v2\0"); + hasher.update(source_json.as_bytes()); + hasher.update([0]); + } else { + hasher.update(b"ticket-role-assignment:set:v1\0"); + } + for value in [ + record.workspace_id.as_str(), + record.ticket_id.as_str(), + record.role.as_str(), + principal_json.as_str(), + record.assigned_by.as_str(), + expected_assignment_id.unwrap_or(""), + if allow_reassign { "reassign" } else { "assign" }, + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let request_fingerprint = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let (principal_id, runtime_id, worker_id) = match &record.principal { + TicketAssignmentPrincipal::User { account_id } => { + (Some(account_id.as_str()), None, None) + } + TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())), + TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + (Some(agent_key.as_str()), None, None) + } + }; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(source)=source {assignment_tracking::require_assignment_source(&tx,&record.workspace_id,source)?;} + if let Some(worker) = record.principal.worker() { + ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; + } + + let existing_operation: Option<(Option, Option)> = tx + .query_row( + "SELECT request_fingerprint, assignment_id + FROM ticket_assignment_operations + WHERE workspace_id = ?1 AND operation_id = ?2", + params![record.workspace_id, operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((persisted_fingerprint, assignment_id)) = existing_operation { + if persisted_fingerprint.as_deref() != Some(request_fingerprint.as_str()) { + return Err(Error::TicketAssignmentConflict(format!( + "operation `{operation_id}` was already used for different Ticket assignment input" + ))); + } + let assignment_id = assignment_id.ok_or_else(|| { + Error::Store(format!( + "Ticket role assignment operation `{operation_id}` is missing assignment identity" + )) + })?; + let persisted = read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict(format!( + "assignment `{assignment_id}` recorded by operation `{operation_id}` no longer exists" + )) + })?; + tx.commit()?; + return Ok(persisted); + } + + ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, + record.principal.worker().as_ref(), operation_id)?; + let current_sql = ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ + ORDER BY a.assigned_at, a.assignment_id", + ); + let mut current_stmt = tx.prepare(¤t_sql)?; + let current = current_stmt + .query_map( + params![record.workspace_id, record.ticket_id, record.role.as_str()], + read_ticket_role_assignment_record, + )? + .collect::>>()?; + drop(current_stmt); + + let replaced = if let Some(expected_assignment_id) = expected_assignment_id { + let Some(expected) = current + .iter() + .find(|assignment| assignment.assignment_id == expected_assignment_id) + else { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` expected assignment `{expected_assignment_id}`, current is {:?}", + record.ticket_id, + record.role.as_str(), + current.first().map(|value| value.assignment_id.as_str()) + ))); + }; + if !allow_reassign { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` is already assigned as `{}`", + record.ticket_id, + record.role.as_str(), + expected.assignment_id + ))); + } + Some(expected.clone()) + } else if record.role.is_singleton() && !current.is_empty() { + let active: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)", + params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?; + if active && record.role != TicketAssignmentRole::Orchestrator { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} already has unfinished {} work; use explicit reassign", + record.ticket_id, record.role.as_str()))); + } + Some(current[0].clone()) + } else { + None + }; + + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id, operation_id, action, ticket_id, role, principal_kind, + principal_id, runtime_id, worker_id, assignment_id, + expected_assignment_id, created_at, request_fingerprint + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + record.workspace_id, + operation_id, + if replaced.is_some() { "reassign" } else { "assign" }, + record.ticket_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assignment_id, + expected_assignment_id, + record.assigned_at, + request_fingerprint, + ], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, role, principal_kind, + principal_id, runtime_id, worker_id, assigned_by, assigned_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.assignment_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_by, + record.assigned_at, + ], + )?; + if let Some(previous) = &replaced { + tx.execute( + "DELETE FROM ticket_current_worker_assignments + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + previous.assignment_id, + ], + )?; + } + tx.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, role, assignment_id, principal_kind, + principal_id, runtime_id, worker_id, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + record.assignment_id, + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_at, + ], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignment_events ( + workspace_id, ticket_id, role, event_id, action, assignment_id, + previous_assignment_id, actor, created_at, operation_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + event_id, + if replaced.is_some() { "reassigned" } else { "assigned" }, + record.assignment_id, + replaced.as_ref().map(|value| value.assignment_id.as_str()), + record.assigned_by, + record.assigned_at, + operation_id, + ], + )?; + tx.commit()?; + Ok(record.clone()) + }) + } +} + fn read_ticket_role_assignment_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -11527,22 +11716,53 @@ fn clear_current_ticket_role_assignment_in_tx( occurred_at: &str, reason: Option<&str>, fingerprint_domain: &str, +) -> Result { + clear_current_ticket_role_assignment_with_source_in_tx( + tx, + workspace_id, + ticket_id, + role, + assignment_id, + event_id, + operation_id, + actor, + occurred_at, + reason, + fingerprint_domain, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn clear_current_ticket_role_assignment_with_source_in_tx( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + fingerprint_domain: &str, + source: Option<&TicketOrchestratorAssignmentSourceContext>, ) -> Result { let current = read_ticket_role_assignment_by_id(tx, workspace_id, assignment_id)?; let Some(current) = current.filter(|value| value.ticket_id == ticket_id && value.role == role) else { return Ok(false); }; - ensure_no_competing_ticket_claim( - tx, - workspace_id, - ticket_id, - current.principal.worker().as_ref(), - operation_id, - )?; let principal_json = serde_json::to_string(¤t.principal) .map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?; let mut hasher = Sha256::new(); + if let Some(source) = source { + hasher.update( + serde_json::to_vec(&(source, reason)) + .map_err(|error| Error::Store(error.to_string()))?, + ); + hasher.update([0]); + } for value in [ fingerprint_domain, workspace_id, @@ -11571,6 +11791,26 @@ fn clear_current_ticket_role_assignment_in_tx( (Some(agent_key.as_str()), None, None) } }; + let persisted:Option>=tx.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|row|row.get(0)).optional()?; + if let Some(persisted) = persisted { + if persisted.as_deref() != Some(fingerprint.as_str()) { + return Err(Error::TicketAssignmentConflict(format!( + "operation {operation_id} was already used for different assignment input or authenticated source" + ))); + } + return Ok(true); + } + ensure_no_competing_ticket_claim( + tx, + workspace_id, + ticket_id, + current.principal.worker().as_ref(), + operation_id, + )?; + let is_current:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role=?3 AND assignment_id=?4)",params![workspace_id,ticket_id,role.as_str(),assignment_id],|row|row.get(0))?; + if !is_current { + return Ok(false); + } let inserted = tx.execute( "INSERT OR IGNORE INTO ticket_assignment_operations ( workspace_id, operation_id, action, ticket_id, role, principal_kind, @@ -13186,7 +13426,34 @@ fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 91 { + return Err(Error::Store( + "Ticket tracking migration requires schema 91".into(), + )); + } + let tx = conn.unchecked_transaction()?; + ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) + .map_err(|error| Error::Store(error.to_string()))?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (92, 'Ticket-owned assignment tracking')", [])?; + verify_workspace_resource_constraints(&tx)?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + create_workspace_schema_structure(conn)?; + ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "fresh Workspace Ticket schema verification failed: {error}" + )) + }) +} + +// Workspace 92 changes only the Ticket-owned assignment component. Retained +// Workspace-91 fixtures compose this host structure with the genuine Ticket-10 +// schema, not a current database with lowered migration markers. +fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { // The composed Workspace parent FK rebuild must not discard domain-owned // case guards. Retain their SQL from the shared fresh Ticket schema. let ticket_parent_triggers = conn @@ -13204,7 +13471,10 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; - conn.execute_batch(include_str!("latest_schema.sql"))?; + conn.execute_batch(&include_str!("latest_schema.sql").replace( + "-- Ticket-owned assignment schema is composed by store.rs.", + ticket::TICKET_ASSIGNMENT_SCHEMA_SQL, + ))?; conn.execute_batch( r#" ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; @@ -13274,11 +13544,6 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { for sql in ticket_parent_triggers { conn.execute_batch(&sql)?; } - ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "fresh Workspace Ticket schema verification failed: {error}" - )) - })?; Ok(()) } @@ -14123,6 +14388,7 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result> { #[cfg(test)] mod tests { + include!("assignment_tracking_tests.rs"); include!("store_workspace_config_tests.rs"); fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse { @@ -16019,7 +16285,11 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn prepare_schema_v52(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + // Retained fixtures start from genuine pre-tracking Ticket DDL; current + // component 11 would introduce assignment objects before frozen host 85. + conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap(); merge_request::migrate(&conn).unwrap(); conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") .unwrap(); @@ -16327,6 +16597,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 91, name: "Ticket open/closed cases and retained routing evidence".to_string() }, + WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()}, ] ); @@ -16428,6 +16699,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (89, "descriptive legacy evidence columns".to_string()), (90, "Runtime binding connection configuration and historical verification archive".to_string()), (91, "Ticket open/closed cases and retained routing evidence".to_string()), +(92, "Ticket-owned assignment tracking".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -18839,7 +19111,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec> { - fixture_query_rows(conn, &format!("SELECT * FROM {table} ORDER BY rowid")) + // Compare preserved original assignment evidence, not newly added tracking columns. + let columns = if table == "ticket_worker_assignments" { + "workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at" + } else { + "*" + }; + fixture_query_rows( + conn, + &format!("SELECT {columns} FROM {table} ORDER BY rowid"), + ) } fn fixture_query_rows(conn: &Connection, sql: &str) -> Vec> { @@ -19009,16 +19290,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, version); - assert_eq!(plan.target_schema_version, 91); + assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION); assert_eq!( plan.migrations .iter() .map(|step| step.version) .collect::>(), if version == 89 { - vec![90, 91] + vec![90, 91, 92] } else { - vec![91] + vec![91, 92] } ); drop(conn); @@ -19026,13 +19307,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(fixture_auth_rows(conn), auth_before); verify_runtime_binding_connection_schema(conn)?; ticket::verify_sqlite_ticket_schema(conn).unwrap(); assert_eq!(workspace_schema_migration_history(conn)?.into_iter().filter(|(version, _)| *version >= 90).collect::>(), vec![(90, "Runtime binding connection configuration and historical verification archive".into()), - (91, "Ticket open/closed cases and retained routing evidence".into())]); + (91, "Ticket open/closed cases and retained routing evidence".into()), + (92, "Ticket-owned assignment tracking".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -19190,7 +19472,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let reopened = SqliteWorkspaceStore::open(&path).unwrap(); reopened .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(fixture_auth_rows(conn), auth_before); Ok(()) }) @@ -19420,7 +19702,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } for store in [&fresh, &upgraded] { store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); verify_workspace_resource_constraints(conn)?; verify_runtime_binding_connection_schema(conn)?; ticket::verify_sqlite_ticket_schema(conn).unwrap(); @@ -20699,30 +20981,60 @@ INSERT INTO worker_registry ( .unwrap(), None ); - store - .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(); + // A committed receipt is not rollbackable evidence. Compensation is a + // new unassign operation and preserves the original identity/audit. + assert!( + store + .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") + .is_err() + ); assert_eq!( store .get_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(), - None + .unwrap() + .and_then(|operation| operation.assignment_id), + Some("assignment-3".into()) ); assert_eq!( store .get_current_ticket_worker_assignment("workspace-a", "ticket-3") .unwrap(), - None + Some(reserved_assignment.clone()) + ); + assert_eq!( + store + .clear_current_ticket_worker_assignment( + "workspace-a", + "ticket-3", + Some("assignment-3"), + "compensate-reserved-operation", + "compensation-event", + "user-1", + "2026-07-32T00:00:04Z" + ) + .unwrap(), + Some(reserved_assignment) ); assert!( + store + .get_current_ticket_worker_assignment("workspace-a", "ticket-3") + .unwrap() + .is_none() + ); + assert_eq!( store .list_ticket_worker_assignment_events("workspace-a", "ticket-3", 10) .unwrap() - .is_empty() + .iter() + .map(|event| event.action.as_str()) + .collect::>(), + BTreeSet::from(["unassigned", "assigned"]) + ); + assert!( + store + .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") + .is_err() ); - store - .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(); let events = store .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs index 72c97c6d8..0f1e65591 100644 --- a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -265,10 +265,20 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64 drop(conn); let store = SqliteWorkspaceStore::open(&path).unwrap(); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,91); - assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10); + assert_eq!(current_schema_version(conn)?,92); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11); assert_eq!(fixture_auth_rows(conn),auth_before); - for (table, rows) in preserved_tables.iter().zip(&preserved) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); } + for (table, rows) in preserved_tables.iter().zip(&preserved) { + let mut migrated = stored_fixture_rows(conn, table); + if *table == "ticket_worker_assignments" { + // Component 11 adds handling columns to the same identity row. + // Compare the ten retained identity/principal/Assign columns, + // and separately prove the historical Worker acquired no tracking. + for row in &mut migrated { row.truncate(10); } + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE tracking_json IS NOT NULL", [], |row| row.get::<_,i64>(0))?, 0); + } + assert_eq!(&migrated, rows, "preserve {table}"); + } if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);} assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1); assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0); @@ -483,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, 92); assert_eq!( conn.query_row( "SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_, i64>(0) )?, - 10 + 11 ); assert_eq!( stored_fixture_rows(conn, "typed_ticket_case_history"), diff --git a/crates/workspace-server/src/worker_source/runtime_request.rs b/crates/workspace-server/src/worker_source/runtime_request.rs index 5e8b4a619..9e79fd3b1 100644 --- a/crates/workspace-server/src/worker_source/runtime_request.rs +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -12,6 +12,9 @@ use crate::store::ControlPlaneStore; pub struct VerifiedRuntimeRequestSource { pub runtime_id: String, pub worker_id: Option, + /// The binding actually used for signature verification. Mutation boundaries + /// compare this identity with current trust inside their write transaction. + pub authentication_binding_id: String, /// Existing proof JTI correlation, retained only as a fixed-length diagnostic fingerprint. pub token_id_hash: Option, } @@ -273,5 +276,6 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( token_id_hash: Some(diagnostic_id_hash(&claims.jti)), runtime_id: claims.iss, worker_id: claims.worker_id, + authentication_binding_id: trusted.binding_id, }) } diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 0bb170c59..f81403e7e 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -52,6 +52,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "ticket_current_worker_assignments", "ticket_worker_assignment_events", "ticket_worker_assignments", + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", "typed_ticket_case_history", "typed_ticket_case_metadata", "typed_ticket_legacy_workflow_snapshot", @@ -464,15 +466,21 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { } let workspace_id = operation.response.workspace_id.clone(); - // Case judgments and legacy snapshots are immutable under ordinary - // Ticket operations, including Ticket deletion and FK cascades. Only - // this owner-authorized whole-Workspace purge may discard them. + // Ticket case/assignment judgments and their operation receipts are + // immutable under ordinary domain operations, including Ticket + // deletion and FK cascades. Only this owner-authorized whole-Workspace + // purge may discard them. // SQLite transactional DDL holds the writer lock: no concurrent writer // can observe the suspended guards, and any error rolls back both // data and schema. Restore the exact schema-owned definitions before // commit; update guards remain installed throughout. - let mut case_delete_guards = Vec::new(); + let mut ticket_delete_guards = Vec::new(); for (table, trigger) in [ + ("ticket_worker_assignments", "typed_ticket_assignment_identity_no_delete"), + ("ticket_worker_assignment_events", "typed_ticket_assignment_events_no_delete"), + ("ticket_assignment_operations", "typed_ticket_assignment_receipt_no_delete"), + ("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"), + ("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"), ("typed_ticket_case_history", "typed_ticket_case_history_no_delete"), ("typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_no_delete"), ] { @@ -482,7 +490,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { |row| row.get(0), )?; tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?; - case_delete_guards.push(definition); + ticket_delete_guards.push(definition); } for table in WORKSPACE_DELETION_PURGE_TABLES { tx.execute( @@ -499,7 +507,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace disappeared before deletion finalized".to_string(), )); } - for definition in case_delete_guards { + for definition in ticket_delete_guards { tx.execute_batch(&definition)?; } let now = Utc::now().to_rfc3339(); @@ -978,6 +986,57 @@ mod tests { .create_with_snapshot(ticket::NewTicket::new("Case evidence")) .unwrap() .1; + let assignment_id = format!("delegation-{workspace}"); + store + .set_current_ticket_role_assignment( + &crate::store::TicketRoleAssignmentRecord { + workspace_id: workspace.into(), + ticket_id: initial.meta.id.clone(), + assignment_id: assignment_id.clone(), + role: crate::store::TicketAssignmentRole::Orchestrator, + principal: crate::store::TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + assigned_by: "owner".into(), + assigned_at: "2026-01-01T00:00:00Z".into(), + }, + None, + "assigned", + "assign", + false, + ) + .unwrap(); + // Seed domain handling evidence under a transaction. This fixture + // exercises owner purge, not singleton authentication or delivery. + store + .with_transaction(|tx| { + ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + tx, + workspace, + &initial.meta.id, + &ticket::TicketAssignmentTrackingUpdate { + assignment_id: assignment_id.clone(), + expected_last_event_id: "assigned".into(), + operation_key: "reported".into(), + tracking: ticket::TicketAssignmentTracking { + status: ticket::TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: None, + next_action: None, + result: Some("Returned result".into()), + }, + }, + &ticket::TicketAssignmentTrackingSourceContext { + actor: "orchestrator".into(), + runtime_id: "fixture-runtime".into(), + worker_id: "fixture-worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "fixture-binding".into(), + }, + )?; + Ok(()) + }) + .unwrap(); backend .update_case( &initial.meta.id, @@ -1022,7 +1081,7 @@ mod tests { } let assert_immutable = |workspace: &str| { store.with_conn(|conn| { - for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot"] { + for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] { assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err()); assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err()); } @@ -1052,8 +1111,13 @@ mod tests { "typed_ticket_case_history", "typed_ticket_case_metadata", "typed_ticket_legacy_workflow_snapshot", + "ticket_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", ]; - let other_before: Vec = store + let other_before: Vec> = store .with_conn(|conn| { tables .iter() @@ -1095,7 +1159,12 @@ mod tests { [], |row| row.get(0), )?; - assert_eq!(count, 1, "rollback must restore {table}"); + let expected = if table == "typed_ticket_assignment_tracking_history" { + 2 + } else { + 1 + }; + assert_eq!(count, expected, "rollback must restore {table}"); } conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?; Ok(()) @@ -1132,7 +1201,7 @@ mod tests { |row| row.get(0), )?; assert_eq!(count, 0, "purge must remove {table}"); - let after: String = conn.query_row( + let after: Option = conn.query_row( &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), [], |row| row.get(row.as_ref().column_count() - 1), diff --git a/docs/development/ticket-assignment-tracking.md b/docs/development/ticket-assignment-tracking.md new file mode 100644 index 000000000..fe71acde3 --- /dev/null +++ b/docs/development/ticket-assignment-tracking.md @@ -0,0 +1,47 @@ +# Ticket assignment tracking contract (T-741) + +## Authority and identities + +Ticket component schema 11 owns assignment identities, current pointers, assignment audit/receipts, and the self-reported handling data. The historical physical names `ticket_worker_assignments`, `ticket_current_worker_assignments`, `ticket_worker_assignment_events`, and `ticket_assignment_operations` are retained; they are not another Workspace assignment authority. Workspace schema 92 composes these Ticket-owned definitions and keeps host principal/resource constraints. + +An immutable `assignment_id` identifies one delegation. Progress updates modify that assignment row; a new Assign/reissue operation creates another row even for the same principal. An exact retry returns the saved result, not a new delegation or a fresh observation. + +The Orchestrator principal is `workspace_agent:workspace-orchestrator`. Backend resolves its current registered Worker. Worker stop, idle, restore, replacement, MR changes and notification delivery do not update handling status or create assignment rows. + +Worker creation reservations, attachment/grant recovery, actual unfinished work, and identity-specific work release remain operational responsibilities. Handling `done` is not work release, Worker termination, resource cleanup, MR approval/integration, or a Ticket close judgment. + +## Self-report and concurrency + +The handling payload contains `status` (`waiting`, `inprogress`, `done`) and nullable `waiting_reason`, `progress`, `next_action`, and `result`. Assign records initial waiting/initial judgment pending together with the current pointer, audit event, and receipt in one transaction. + +The update input is `assignment_id`, `expected_last_event_id`, `operation_key`, and the complete handling payload. The observed event belongs to that exact delegation; comparing status alone would miss concurrent changes. No ordered status progression, MR or approval requirement applies. Direct waiting-to-done is valid. + +Backend constructs authenticated actor/source context; model input cannot supply role, actor, Runtime or Worker authority. The mutation transaction validates current singleton ownership and the Runtime binding actually used to verify the request. Full input/source must match an existing receipt. The receipt check distinguishes an old operation retry from a new update to a released/replaced delegation; only the latter requires current assignment/event CAS. Worker replacement does not let the old sender replay operations. + +The public Orchestrator Assign/clear paths also bind receipts to Backend-authenticated Runtime/Worker/agent/binding context or the authenticated account/user pair, separately from readable Assign/audit actor text. Runtime calls fence singleton and binding inside the write transaction even for retries. Account authoring retains its authenticated Workspace permission boundary and does not require a Runtime identity. Migration preserves old receipts without inventing a historical binding proof. + +Close/reopen does not change handling status. Current Orchestrator result recording remains authorized after close without requiring an active-worker assignment or restoring old execution grants. Reopen by itself creates neither a new delegation nor old Worker execution rights. + +## Public surfaces + +Canonical Workspace endpoints: + +- `GET /workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking`: current Orchestrator assignment projection. Optional `assignment_id` selects a retained assignment, including its last handling and independent release/replacement audit. +- `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. + +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. + +Update identities and operation keys are nonempty, control-character-free and bounded to 1024 bytes. Each optional handling text is bounded to 16384 UTF-8 bytes and cannot contain NUL. History cursors must belong to the same Workspace, Ticket and assignment. Read-only tool access includes query/history, never update; enabling a tool is not Backend authorization. + +## Migration and downstream handoff + +Ticket 10→11 and Workspace 91→92 append migrations; applied migrations are not rewritten. Original workflow snapshots, Queue assignment fences, assignment identities/audit/receipts and subsequent case decisions are retained and reconciled. Current fenced queued/in-progress delegation can be retained with its migration evidence. Historical, ambiguous, or superseded relationships retain original data plus diagnostics with unknown handling (`tracking: null`); neither successful done nor a fresh execution request is inferred. + +For retained current relationships with unknown handling, an existing matching Assign audit event can serve as the observed `last_event_id` baseline for the first explicit self-report, including after close. This is not a new handling event or an initial waiting value. The first self-report replaces that baseline with its own event; concurrent updates then fail normal CAS. If original Assign audit is missing, keep the diagnostic rather than inventing an event. Handling-history cursors remain identities returned by that history, not this Assign baseline. + +T-742 consumes this authority for delivery/acceptance/reconsideration cursors and startup/create/restore/idle scans. It must distinguish committed new Assign events from legacy unknown relationships, and bind actual acceptance/start reservations to current assignment identity. It owns replacement of the old Queue/state routing predicate and unfinished-dependency start refusals. Waiting alone is not a reason for repeated delivery, and updating one's own progress must not become an input busy loop. + +T-743 owns the final UI/Prompt operation model. Classification, Ticket case state, handling, delivery/consideration position, and actual Worker/grant lifecycle remain separate. This contract does not implement live DB migration or update the running environment. diff --git a/docs/report/2026-10-11-subworker-advisory-surface-gap.md b/docs/report/2026-10-11-subworker-advisory-surface-gap.md new file mode 100644 index 000000000..4f68f1c84 --- /dev/null +++ b/docs/report/2026-10-11-subworker-advisory-surface-gap.md @@ -0,0 +1,15 @@ +# SubWorker実装中の契約接点の伝達 + +## 観測 + +T-741のdomain/store担当とserver認可/router担当を並行委任した。Worker管理の説明では実行中のWorkerへadvisoryを送る`WorkerNotify`が案内されていたが、親に提供されたtool inventoryは`WorkerList` / `WorkerSendInput` / `WorkerStop` / session observationで、`WorkerNotify`はなかった。 + +実装中に、認証済Runtime bindingをwrite transaction内でも照合する必要と、Assign/解除receiptにも同じsource-bound比較が必要という接点が判明した。Running childへの`WorkerSendInput`は使わず、session observationで子の実際の契約・diffを読み、子がIdleへ戻った自然な境界で追加依頼を送った。未完了の子を止めたり、入力の拒否を回避したりはしていない。 + +## 障壁と改善案 + +- 説明が存在しても実際のcapabilityがないため、同じ依存契約の追記をRunning中の担当へ早く伝えられない。双方がコンパイルエラーや作業木から接点を再発見する時間が増える。 +- capabilityがある場合だけ実行中advisoryの説明を提示するか、明示的な親→直接子のadvisory toolを公開する。通常SubmitやIdle-onlyの入力と混同しない。 +- 付与しない場合は、短い契約確定チェックポイントを分担の最初に設けることが現状の回避策になる。これは一般的な自動停止・polling・再起動の許可ではない。 + +本記録はこのSessionのtool提供面の観測であり、Backendの全環境に同機能がないと断定するものではない。 diff --git a/openapi/server-api.json b/openapi/server-api.json index 65f364853..78819a744 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -69,6 +69,12 @@ }, "type": "array" }, + "Array_of_TicketAssignmentTrackingProjection": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "Array_of_TicketRelation": { "items": { "$ref": "#/components/schemas/TicketRelation" @@ -10639,6 +10645,122 @@ } ] }, + "TicketAssignmentPrincipalSnapshot": { + "description": "Immutable principal as stored at Assign, not live Worker availability.", + "oneOf": [ + { + "properties": { + "account_id": { + "type": "string" + }, + "kind": { + "const": "user", + "type": "string" + } + }, + "required": [ + "kind", + "account_id" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + { + "properties": { + "agent_key": { + "type": "string" + }, + "kind": { + "const": "workspace_agent", + "type": "string" + } + }, + "required": [ + "kind", + "agent_key" + ], + "type": "object" + } + ] + }, + "TicketAssignmentRelease": { + "description": "Release is an independent immutable assignment audit event. Last tracking\nremains unchanged, including unknown legacy tracking; it is never made done.", + "properties": { + "action": { + "$ref": "#/components/schemas/TicketAssignmentReleaseAction" + }, + "actor": { + "type": "string" + }, + "at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "last_tracking": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTracking" + }, + { + "type": "null" + } + ] + }, + "operation_key": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "replacement_assignment_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action", + "event_id", + "actor", + "at" + ], + "type": "object" + }, + "TicketAssignmentReleaseAction": { + "enum": [ + "unassigned", + "replaced" + ], + "type": "string" + }, "TicketAssignmentRole": { "enum": [ "orchestrator", @@ -10674,6 +10796,239 @@ ], "type": "object" }, + "TicketAssignmentTracking": { + "additionalProperties": false, + "properties": { + "next_action": { + "type": [ + "string", + "null" + ] + }, + "progress": { + "type": [ + "string", + "null" + ] + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/TicketAssignmentTrackingStatus" + }, + "waiting_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryEntry": { + "properties": { + "at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "source_context": true, + "tracking": { + "$ref": "#/components/schemas/TicketAssignmentTracking" + } + }, + "required": [ + "event_id", + "operation_key", + "tracking", + "source_context", + "at" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingHistoryEntry" + }, + "type": "array" + }, + "next_event_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryQuery": { + "additionalProperties": false, + "properties": { + "after_event_id": { + "type": [ + "string", + "null" + ] + }, + "assignment_id": { + "type": "string" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "assignment_id" + ], + "type": "object" + }, + "TicketAssignmentTrackingProjection": { + "description": "Enrichment is separate from the established assignment record. `None`\ntracking means a retained historical relationship with insufficient delegation\nevidence, not waiting/done. Removal/replacement does not alter last tracking.", + "properties": { + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "assignment_id": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "last_event_id": { + "description": "Tracking event, or the existing immutable Assign audit observation for a\nretained current relationship with no tracking yet. This baseline does\nnot imply a handling status, delegation intent, or tracking timestamp.", + "type": [ + "string", + "null" + ] + }, + "migration_diagnostic": { + "type": [ + "string", + "null" + ] + }, + "principal": { + "$ref": "#/components/schemas/TicketAssignmentPrincipalSnapshot" + }, + "release": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentRelease" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "ticket_id": { + "type": "string" + }, + "tracking": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTracking" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at", + "current" + ], + "type": "object" + }, + "TicketAssignmentTrackingQuery": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "description": "Omit for the current delegation; provide to inspect a retained assignment.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketAssignmentTrackingStatus": { + "enum": [ + "waiting", + "inprogress", + "done" + ], + "type": "string" + }, + "TicketAssignmentTrackingUpdate": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": "string" + }, + "expected_last_event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "tracking": { + "$ref": "#/components/schemas/TicketAssignmentTracking" + } + }, + "required": [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking" + ], + "type": "object" + }, "TicketAttention": { "enum": [ "unresolved_review", @@ -10957,6 +11312,12 @@ }, "type": "array" }, + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "assignments": { "items": { "$ref": "#/components/schemas/TicketRoleAssignmentSummary" @@ -11159,6 +11520,7 @@ "linked_objectives", "implementation_reports", "assignments", + "assignment_tracking", "assignment_diagnostics", "action_eligibility", "merge_requests", @@ -12246,6 +12608,12 @@ "TicketQueryItem": { "additionalProperties": false, "properties": { + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "blocker_count": { "format": "uint", "maximum": 9007199254740991, @@ -12423,6 +12791,7 @@ "blocker_count", "unresolved_blocker_count", "unresolved_review_count", + "assignment_tracking", "evidence", "merge_requests" ], @@ -16444,7 +16813,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3" + "x-yoi-source-digest": "sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774" }, "openapi": "3.1.0", "paths": { @@ -38507,6 +38876,345 @@ } } }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking": { + "get": { + "operationId": "ticket_assignment_tracking_get", + "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/TicketAssignmentTrackingQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_TicketAssignmentTrackingProjection" + } + } + }, + "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": [] + } + ] + }, + "post": { + "operationId": "ticket_assignment_tracking_update", + "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/TicketAssignmentTrackingUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + } + } + }, + "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-tracking/history": { + "get": { + "operationId": "ticket_assignment_tracking_history", + "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/TicketAssignmentTrackingHistoryQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingHistoryPage" + } + } + }, + "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}/case": { "post": { "operationId": "ticket_case_update", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 1d5aacf2f..88c4da3bb 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 59abffb71..a3f8fc78e 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 3acfbbd50..7a78b1505 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 0983808b9..60e817c7a 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,10 +1,12 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check +export type Array_of_TicketAssignmentTrackingProjection = Array; + export type Array_of_WorkspaceTag = Array; export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; @@ -87,10 +89,28 @@ export type TicketActionEligibility = { blockers: Array; can_assign_orch 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"; }; + +export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; }; + +export type TicketAssignmentReleaseAction = "unassigned" | "replaced"; + export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor"; 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; }; + +export type TicketAssignmentTrackingHistoryEntry = { at: string; event_id: string; operation_key: string; source_context: unknown; tracking: TicketAssignmentTracking; }; + +export type TicketAssignmentTrackingHistoryPage = { items: Array; next_event_id?: string | null; }; + +export type TicketAssignmentTrackingProjection = { assigned_at: string; assigned_by: string; assignment_id: string; current: boolean; last_event_id?: string | null; migration_diagnostic?: string | null; principal: TicketAssignmentPrincipalSnapshot; release?: TicketAssignmentRelease | null; role: string; ticket_id: string; tracking?: TicketAssignmentTracking | null; updated_at?: string | null; workspace_id: string; }; + +export type TicketAssignmentTrackingStatus = "waiting" | "inprogress" | "done"; + +export type TicketAssignmentTrackingUpdate = { assignment_id: string; expected_last_event_id: string; operation_key: string; tracking: TicketAssignmentTracking; }; + 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; }; @@ -103,7 +123,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: BrowserTicketState; tags: Array; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignment_tracking: Array; assignments: Array; body: string; body_truncated: boolean; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: BrowserTicketState; tags: Array; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -133,7 +153,7 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; }; -export type TicketQueryItem = { 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 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; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index d250b63c9..8db0ab85e 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index 1069c0a2f..23735a03b 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -139,6 +139,7 @@ function ticketDetailFixture() { artifact_count: 0, artifacts: [], assignment_diagnostics: [], + assignment_tracking: [], assignments: [], closure_history: [], closure_operation_key: null, @@ -195,6 +196,52 @@ function ticketDetailFixture() { }; } +Deno.test("Ticket Browser keeps self-reported delegation independent of case and Worker state", () => { + const tracking = { + workspace_id: "workspace-a", + ticket_id: "ticket-id", + assignment_id: "delegation", + role: "orchestrator", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "workspace-user:alice", + assigned_at: "2026-01-01T00:00:00Z", + release: null, + current: true, + last_event_id: "report-event", + updated_at: null, + migration_diagnostic: null, + tracking: { + status: "done", + waiting_reason: null, + progress: "Investigated", + next_action: "Human decision", + result: "Alternatives returned", + }, + }; + for (const state of ["open", "closed"]) { + const parsed = parseTicketDetail({ + ...ticketDetailFixture(), + state, + assignment_tracking: [tracking], + }); + assertEquals(parsed.assignment_tracking[0]?.tracking?.status, "done"); + assertEquals(parsed.assignment_tracking[0]?.last_event_id, "report-event"); + assertEquals(parsed.state, state); + assertEquals(parsed.current_worker, null); + } + assertThrows( + () => + parseTicketDetail({ + ...ticketDetailFixture(), + assignment_tracking: [{ + ...tracking, + tracking: { ...tracking.tracking, status: "running" }, + }], + }), + ".status is invalid", + ); +}); + Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { const fixture = ticketDetailFixture(); const parsed = parseTicketDetail(fixture); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 778be9a84..6b2135702 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -20,7 +20,10 @@ import type { ReviewFinding, TicketActionEligibility, TicketAssignmentPrincipal, + TicketAssignmentRelease, TicketAssignmentSummary, + TicketAssignmentTracking, + TicketAssignmentTrackingProjection, TicketCaseAction, TicketCaseHistoryEntry, TicketClosureJudgment, @@ -649,6 +652,130 @@ function parseTicketRoleAssignmentRecord( }; } +function parseAssignmentTracking( + value: unknown, + label: string, +): TicketAssignmentTracking { + const item = object(value, label); + exact( + item, + ["status", "waiting_reason", "progress", "next_action", "result"], + label, + ); + const status = string(item.status, `${label}.status`); + if (status !== "waiting" && status !== "inprogress" && status !== "done") { + throw new Error(`${label}.status is invalid`); + } + return { + status, + waiting_reason: optionalNullableString( + item.waiting_reason, + `${label}.waiting_reason`, + ), + progress: optionalNullableString(item.progress, `${label}.progress`), + next_action: optionalNullableString( + item.next_action, + `${label}.next_action`, + ), + result: optionalNullableString(item.result, `${label}.result`), + }; +} + +function parseAssignmentRelease( + value: unknown, + label: string, +): TicketAssignmentRelease { + const item = object(value, label); + exact(item, [ + "action", + "event_id", + "operation_key", + "actor", + "at", + "reason", + "replacement_assignment_id", + "last_tracking", + ], label); + const action = string(item.action, `${label}.action`); + if (action !== "unassigned" && action !== "replaced") { + throw new Error(`${label}.action is invalid`); + } + return { + action, + event_id: string(item.event_id, `${label}.event_id`), + operation_key: optionalNullableString( + item.operation_key, + `${label}.operation_key`, + ), + actor: string(item.actor, `${label}.actor`), + at: string(item.at, `${label}.at`), + reason: optionalNullableString(item.reason, `${label}.reason`), + replacement_assignment_id: optionalNullableString( + item.replacement_assignment_id, + `${label}.replacement_assignment_id`, + ), + last_tracking: item.last_tracking === undefined + ? undefined + : item.last_tracking === null + ? null + : parseAssignmentTracking(item.last_tracking, `${label}.last_tracking`), + }; +} + +function parseAssignmentTrackingProjection( + value: unknown, + label: string, +): TicketAssignmentTrackingProjection { + const item = object(value, label); + exact(item, [ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at", + "release", + "current", + "tracking", + "last_event_id", + "updated_at", + "migration_diagnostic", + ], label); + return { + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + role: string(item.role, `${label}.role`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + release: item.release === undefined + ? undefined + : item.release === null + ? null + : parseAssignmentRelease(item.release, `${label}.release`), + current: boolean(item.current, `${label}.current`), + tracking: item.tracking === undefined + ? undefined + : item.tracking === null + ? null + : parseAssignmentTracking(item.tracking, `${label}.tracking`), + last_event_id: optionalNullableString( + item.last_event_id, + `${label}.last_event_id`, + ), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + migration_diagnostic: optionalNullableString( + item.migration_diagnostic, + `${label}.migration_diagnostic`, + ), + }; +} + function parseActionEligibility( value: unknown, label: string, @@ -961,6 +1088,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "artifact_count", "artifacts", "assignment_diagnostics", + "assignment_tracking", "assignments", "closure_history", "body", @@ -1012,6 +1140,11 @@ export function parseTicketDetail(value: unknown): TicketDetail { item.assignment_diagnostics, `${label}.assignment_diagnostics`, ), + assignment_tracking: array( + item.assignment_tracking, + `${label}.assignment_tracking`, + parseAssignmentTrackingProjection, + ), assignments: array( item.assignments, `${label}.assignments`,