feat(ticket): persist Orchestrator assignment tracking and audit
This commit is contained in:
@@ -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<TicketSummary, RepositoryApiError>;
|
||||
#[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<TicketAssignmentTrackingList, RepositoryApiError>;
|
||||
#[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<TicketAssignmentTrackingRecord, RepositoryApiError>;
|
||||
#[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<TicketAssignmentTrackingHistoryPage, RepositoryApiError>;
|
||||
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn ticket_relation_view(
|
||||
&self,
|
||||
@@ -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<String>,
|
||||
/// 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<TicketAssignmentTrackingProjection>")
|
||||
)]
|
||||
pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
@@ -7700,6 +7740,11 @@ pub struct TicketDetail {
|
||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
||||
#[cfg_attr(
|
||||
feature = "typescript",
|
||||
ts(type = "Array<TicketAssignmentTrackingProjection>")
|
||||
)]
|
||||
pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>,
|
||||
pub current_worker: Option<TicketAssignmentSummary>,
|
||||
pub assignment_diagnostics: Vec<String>,
|
||||
pub action_eligibility: TicketActionEligibility,
|
||||
@@ -7777,6 +7822,42 @@ pub struct TicketCaseUpdateRequest {
|
||||
#[serde(default)]
|
||||
pub references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
transparent_ticket_dto!(
|
||||
TicketAssignmentTrackingUpdateRequest,
|
||||
ticket::TicketAssignmentTrackingUpdate
|
||||
);
|
||||
transparent_ticket_dto!(
|
||||
TicketAssignmentTrackingRecord,
|
||||
ticket::TicketAssignmentTrackingProjection
|
||||
);
|
||||
transparent_ticket_dto!(
|
||||
TicketAssignmentTrackingList,
|
||||
Vec<ticket::TicketAssignmentTrackingProjection>
|
||||
);
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentTrackingHistoryQuery {
|
||||
pub assignment_id: String,
|
||||
pub after_event_id: Option<String>,
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentTrackingHistoryPage {
|
||||
pub items: Vec<ticket::TicketAssignmentTrackingHistoryEntry>,
|
||||
pub next_event_id: Option<String>,
|
||||
}
|
||||
|
||||
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::<TicketAssignmentTrackingUpdateRequest>(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::<TicketAssignmentTrackingUpdateRequest>(invalid)
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let mut invalid = input.clone();
|
||||
invalid["tracking"]["status"] = serde_json::json!("running");
|
||||
assert!(
|
||||
serde_json::from_value::<TicketAssignmentTrackingUpdateRequest>(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!({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
@@ -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<String>,
|
||||
pub progress: Option<String>,
|
||||
pub next_action: Option<String>,
|
||||
pub result: Option<String>,
|
||||
}
|
||||
|
||||
#[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<TicketAssignmentRelease>,
|
||||
pub current: bool,
|
||||
pub tracking: Option<TicketAssignmentTracking>,
|
||||
/// 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<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub migration_diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub actor: String,
|
||||
pub at: String,
|
||||
pub reason: Option<String>,
|
||||
pub replacement_assignment_id: Option<String>,
|
||||
pub last_tracking: Option<TicketAssignmentTracking>,
|
||||
}
|
||||
|
||||
#[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<TicketAssignmentTrackingHistoryEntry>,
|
||||
pub next_event_id: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<TicketAssignmentTrackingProjection>> {
|
||||
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<String>>(0)?,r.get::<_,Option<String>>(1)?,r.get::<_,Option<String>>(2)?,r.get::<_,Option<String>>(3)?,r.get::<_,bool>(4)?, r.get::<_,String>(5)?, r.get::<_,String>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,Option<String>>(8)?,r.get::<_,Option<String>>(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<TicketAssignmentTracking> = 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<String>>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Option<String>>(5)?,r.get::<_,Option<String>>(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<Vec<TicketAssignmentTrackingProjection>> {
|
||||
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::<std::result::Result<Vec<_>,_>>().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<Vec<TicketAssignmentTrackingHistoryEntry>> {
|
||||
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<TicketAssignmentTrackingHistoryPage> {
|
||||
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<i64>=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::<Result<Vec<_>>>()?;
|
||||
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<TicketAssignmentTrackingProjection> {
|
||||
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<String>>(3)?,r.get::<_,bool>(4)?))).map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?;
|
||||
for (workspace, ticket, assignment, snapshot, current) in rows {
|
||||
let original = snapshot
|
||||
.as_deref()
|
||||
.map(serde_json::from_str::<TicketLegacyWorkflowSnapshot>)
|
||||
.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<String>>(1)?,r.get::<_,Option<bool>>(2)?.unwrap_or(false),r.get::<_,Option<String>>(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))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
let superseded:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND 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<String> = 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::<std::result::Result<Vec<_>, _>>()
|
||||
.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<Vec<(String, String, i64, Option<String>, 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::<std::result::Result<Vec<_>, _>>()
|
||||
.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<BTreeSet<(String, String, String, String)>> {
|
||||
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::<std::result::Result<BTreeSet<_>, _>>()
|
||||
.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<BTreeSet<(bool, bool, Vec<String>)>> {
|
||||
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::<std::result::Result<Vec<_>, _>>()
|
||||
.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::<std::result::Result<Vec<_>, _>>()
|
||||
.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;
|
||||
@@ -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);
|
||||
@@ -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<String> = 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<TicketAssignmentTrackingProjection> {
|
||||
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::<TicketAssignmentTrackingUpdate>(value).is_err());
|
||||
let mut value = serde_json::to_value(&input).unwrap();
|
||||
value["tracking"]["status"] = serde_json::json!("running");
|
||||
assert!(serde_json::from_value::<TicketAssignmentTrackingUpdate>(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")
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<i64, String>) -> 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());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ struct ModelTicketQueryItem {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
@@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
@@ -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<ticket::TicketAssignmentRelease>,
|
||||
current: bool,
|
||||
tracking: Option<ticket::TicketAssignmentTracking>,
|
||||
last_event_id: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
migration_diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
@@ -266,6 +283,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||
current_closure: typed_field(item, "current_closure")?,
|
||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||
assignment_tracking: project_assignment_tracking(item)?,
|
||||
tags: project_tags(item)?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
@@ -322,6 +340,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
current_closure: typed_field(root, "current_closure")?,
|
||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||
closure_history: typed_field(root, "closure_history")?,
|
||||
assignment_tracking: project_assignment_tracking(root)?,
|
||||
tags: project_tags(root)?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
@@ -672,6 +691,36 @@ fn object_field<'a>(
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn project_assignment_tracking(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<Vec<ModelAssignmentTracking>, 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<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, 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.
|
||||
|
||||
@@ -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<NativeTicketOperation> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
let item = ticket_descriptor(
|
||||
&authoring
|
||||
@@ -2753,22 +2788,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|tool| tool.projection.operation)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"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::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
native_ticket_tools(client, TicketFeatureAccess::review())
|
||||
@@ -2776,7 +2799,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|tool| tool.projection.operation)
|
||||
.collect::<Vec<_>>(),
|
||||
["query", "read", "tag_list"]
|
||||
READ_ONLY_TOOL_NAMES
|
||||
.iter()
|
||||
.map(|name| native_ticket_operation(name).unwrap().operation)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[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<dyn WorkspaceClient>,
|
||||
kind: TrackingToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TrackingTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
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<dyn WorkspaceClient>,
|
||||
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<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Transport {
|
||||
workspace: Option<String>,
|
||||
response: WorkspaceResponse,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
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<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
fn transport(status: u16, body: &str) -> Arc<Transport> {
|
||||
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::<Value>(&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"));
|
||||
}
|
||||
}
|
||||
@@ -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)();
|
||||
|
||||
@@ -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<TicketAssignmentTrackingProjection> {
|
||||
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<Option<TicketAssignmentTrackingProjection>> {
|
||||
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<Vec<TicketAssignmentTrackingProjection>> {
|
||||
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<Vec<TicketAssignmentTrackingHistoryEntry>> {
|
||||
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<ticket::TicketAssignmentTrackingHistoryPage> {
|
||||
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()),
|
||||
})
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Vec<ticket::TicketAssignmentTrackingProjection>> {
|
||||
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<dyn TicketSourceCommitResolver>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<crate::worker_source::VerifiedRuntimeRequestSource>()
|
||||
.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<server_api::TicketAssignmentTrackingList, server_api::RepositoryApiError>
|
||||
{
|
||||
let api = self.workspace_api()?;
|
||||
validate_workspace_scope(api, &workspace_id)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let 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::<Vec<_>>())
|
||||
} 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<WorkspaceApi>,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
request: Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
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<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
request: Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||
Json(request): Json<server_api::SetTicketRoleAssignmentRequest>,
|
||||
assigned_by: String,
|
||||
source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
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<WorkspaceApi>,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
query: Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
apply_clear_ticket_assignment(state, path, query, "workspace-web".into(), None).await
|
||||
}
|
||||
|
||||
async fn scoped_clear_ticket_assignment_with_context(
|
||||
State(api): State<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
path: AxumPath<(String, String, String)>,
|
||||
query: Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||
Query(query): Query<server_api::ClearTicketRoleAssignmentQuery>,
|
||||
author: String,
|
||||
source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>,
|
||||
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
|
||||
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<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
context: &server_api::ServerRequestContext,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
Json(update): Json<ticket::TicketAssignmentTrackingUpdate>,
|
||||
) -> ApiResult<Json<ticket::TicketAssignmentTrackingProjection>> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<server_api::TicketAssignmentTrackingHistoryQuery>,
|
||||
) -> ApiResult<Json<server_api::TicketAssignmentTrackingHistoryPage>> {
|
||||
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<WorkspaceApi>,
|
||||
headers: HeaderMap,
|
||||
@@ -16516,6 +16809,20 @@ async fn scoped_ticket_case_update_with_context(
|
||||
path: AxumPath<(String, String)>,
|
||||
request: Json<ticket::TicketCaseUpdate>,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
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<Option<WorkerMutationSource>> {
|
||||
// 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()),
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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()),
|
||||
}))
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
|
||||
@@ -12,6 +12,9 @@ use crate::store::ControlPlaneStore;
|
||||
pub struct VerifiedRuntimeRequestSource {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<String> = store
|
||||
let other_before: Vec<Option<String>> = 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<String> = conn.query_row(
|
||||
&format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"),
|
||||
[],
|
||||
|row| row.get(row.as_ref().column_count() - 1),
|
||||
|
||||
Reference in New Issue
Block a user