chore: merge develop preserving documentation structure and console UX
Integrate develop at 523bade3 while retaining concise agent guidance, rustdoc-owned design rationale, and current console history/rewind behavior. Adapt incoming tests to public entry-ID rewind and synchronize OpenAPI/TypeScript artifacts.
Validation: default-member cargo test --all-features (4936 passed, 23 ignored), root cargo check, all-feature rustdoc with broken links denied, format/diff/name guards, OpenAPI and 11 TypeScript freshness checks. Web: 613 unit, 327 component, 7 Ticket browser and 3 Console browser tests; check/build passed. Merged-state Ticket visual review at 1440/768/390px in light/dark; no matching pre-merge image baseline. E2E/live-provider checks not run.
This commit is contained in:
@@ -6,6 +6,6 @@ Does not search Repository documents or handle Git integration.
|
||||
## Main entry points
|
||||
|
||||
- [`TicketBackend`], [`SqliteTicketBackend`]: operation boundary and persistence
|
||||
- [`TicketBackend::complete`], [`TicketBackend::update_state`]: completion decisions and state updates
|
||||
- [`TicketBackend::update_case`]: reasoned close, reopen, and closure correction
|
||||
- [`ticket_content_digest`]: requirement-content comparison condition
|
||||
- [`tool`]: Worker-facing operation definitions
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Continuing input is authored against an observed current delegation and handling
|
||||
//! event, never a new Assign. Only open cases with waiting/inprogress handling admit new
|
||||
//! answers; done requires explicit reissue. Answers append input without changing handling.
|
||||
//! The host fences authenticated source/current identity and saves input/receipt atomically,
|
||||
//! then notifies delivery after commit. Exact replay keeps its original key and full input;
|
||||
//! its saved receipt does not attest delivery, model consideration, or execution start.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentAnswer {
|
||||
pub assignment_id: String,
|
||||
pub expected_last_event_id: String,
|
||||
pub operation_key: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentAnswerReceipt {
|
||||
pub assignment_id: String,
|
||||
pub input_event_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub event_index: u64,
|
||||
}
|
||||
|
||||
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
|
||||
/// calling, including on replay. Reuse Ticket's existing immutable operation
|
||||
/// receipts with a tagged input; these are not handling events or another ledger.
|
||||
pub fn sqlite_answer_ticket_assignment_in_transaction(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
answer: &TicketAssignmentAnswer,
|
||||
source_context: &serde_json::Value,
|
||||
actor: &str,
|
||||
) -> Result<TicketAssignmentAnswerReceipt> {
|
||||
for value in [
|
||||
&answer.assignment_id,
|
||||
&answer.expected_last_event_id,
|
||||
&answer.operation_key,
|
||||
actor,
|
||||
] {
|
||||
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer requires bounded nonempty identities".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
|
||||
));
|
||||
}
|
||||
let input =
|
||||
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
|
||||
.map_err(sqlite_err)?;
|
||||
let source = serde_json::to_string(
|
||||
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let receipt: Option<(String,String,String,String)> = conn.query_row(
|
||||
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
|
||||
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
|
||||
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
|
||||
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: answer.operation_key.clone(),
|
||||
});
|
||||
}
|
||||
return serde_json::from_str(&saved_result).map_err(sqlite_err);
|
||||
}
|
||||
let current =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
|
||||
})?;
|
||||
if !current.current
|
||||
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|
||||
|| !current.tracking.as_ref().is_some_and(|tracking| {
|
||||
matches!(
|
||||
tracking.status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
| TicketAssignmentTrackingStatus::Inprogress
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
|
||||
}
|
||||
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
|
||||
if !open {
|
||||
return Err(TicketError::Conflict(
|
||||
"A closed Ticket does not admit continuing work input".into(),
|
||||
));
|
||||
}
|
||||
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
|
||||
let at = now_utc();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
|
||||
for (key, value) in [
|
||||
("orchestrator_assignment_id", answer.assignment_id.as_str()),
|
||||
(
|
||||
"expected_last_event_id",
|
||||
answer.expected_last_event_id.as_str(),
|
||||
),
|
||||
("operation_key", answer.operation_key.as_str()),
|
||||
("authenticated_source", source.as_str()),
|
||||
] {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id, at],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let result = TicketAssignmentAnswerReceipt {
|
||||
assignment_id: answer.assignment_id.clone(),
|
||||
input_event_id: format!("ticket-event:{index}"),
|
||||
event_index: index as u64,
|
||||
};
|
||||
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -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,964 @@
|
||||
//! Ticket-owned delegation identity and self-reported handling, not Runtime execution,
|
||||
//! grants, delivery acknowledgement, or MR evidence. Each explicit Assign/reissue has a
|
||||
//! new immutable identity, even for the same principal; Worker replacement is not Assign.
|
||||
//! Assign commits the current pointer, initial waiting, audit, and receipt together.
|
||||
//!
|
||||
//! Handling has no mandatory progression: waiting-to-done is valid without MR approval.
|
||||
//! Done does not close the case, release work, terminate a Worker, or clean up resources.
|
||||
//! Close/reopen does not change handling; current responsibility can report after close
|
||||
//! without restoring execution grants. Retained unknown handling is not inferred waiting/done.
|
||||
//!
|
||||
//! The host authenticates the current workspace-agent Worker and its actual Runtime
|
||||
//! binding, including retries, inside the same write transaction as receipt comparison,
|
||||
//! current-assignment/event CAS, audit, and result. Never split that transaction around
|
||||
//! awaits. A replayed receipt is not a fresh event observation: reread before a new update,
|
||||
//! and reconcile stale or unknown outcomes instead of silently issuing a new operation key.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Replaces the complete handling payload against this delegation's observed event,
|
||||
/// not status alone. Exact retry compares full input and authenticated sender/binding.
|
||||
#[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;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentsPage {
|
||||
pub items: Vec<TicketAssignmentTrackingProjection>,
|
||||
pub next_assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable append-order identities. The cursor must be an identity in this
|
||||
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
|
||||
pub fn sqlite_ticket_assignments_page(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
after_assignment_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<TicketAssignmentsPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Assignment page limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
let after: i64 = match after_assignment_id {
|
||||
None => 0,
|
||||
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
|
||||
};
|
||||
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
|
||||
let mut ids = stmt
|
||||
.query_map(
|
||||
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
let has_more = ids.len() > limit;
|
||||
ids.truncate(limit);
|
||||
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
|
||||
let items = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(TicketAssignmentsPage {
|
||||
items,
|
||||
next_assignment_id,
|
||||
})
|
||||
}
|
||||
@@ -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,831 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let answer = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Continue with B".into(),
|
||||
};
|
||||
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
|
||||
let apply = |answer: &TicketAssignmentAnswer| {
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let result = sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn, "w", "t", answer, &source, "human",
|
||||
);
|
||||
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
|
||||
.unwrap();
|
||||
result
|
||||
};
|
||||
let result = apply(&answer).unwrap();
|
||||
assert_eq!(result.input_event_id, "ticket-event:0");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
answer.body
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_event_id
|
||||
.as_deref(),
|
||||
Some("assigned-a")
|
||||
);
|
||||
let mut altered = answer.clone();
|
||||
altered.body = "Changed".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
altered.operation_key = "fresh-stale".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let original = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Answer".into(),
|
||||
};
|
||||
for field in [
|
||||
"body-large",
|
||||
"body-nul",
|
||||
"body-empty",
|
||||
"identity-control",
|
||||
"event-empty",
|
||||
] {
|
||||
let mut answer = original.clone();
|
||||
match field {
|
||||
"body-large" => answer.body = "a".repeat(16385),
|
||||
"body-nul" => answer.body = "answer\0hidden".into(),
|
||||
"body-empty" => answer.body = " ".into(),
|
||||
"identity-control" => answer.operation_key = "key\n".into(),
|
||||
_ => answer.expected_last_event_id = "".into(),
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn,
|
||||
"w",
|
||||
"t",
|
||||
&answer,
|
||||
&serde_json::json!({"actor":"source"}),
|
||||
"human"
|
||||
)
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
@@ -131,3 +131,73 @@ CREATE INDEX typed_tickets_workspace_title
|
||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_updated
|
||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
|
||||
CREATE TABLE workspace_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
last_operation_key TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
|
||||
PRIMARY KEY (workspace_id, tag_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_tags_active_name
|
||||
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
|
||||
CREATE TABLE typed_ticket_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, tag_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX typed_ticket_tags_workspace_tag
|
||||
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
|
||||
-- Immutable operation receipt and Workspace audit: exact input includes actor
|
||||
-- attributes; before/result preserve stable identities and display-name snapshots.
|
||||
-- No foreign keys to mutable rows: history survives deletion of current links.
|
||||
CREATE TABLE workspace_tag_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
input_json TEXT NOT NULL,
|
||||
source_context_json TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_key)
|
||||
);
|
||||
|
||||
+588
-2034
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,16 @@
|
||||
//! Ticket schema authority is the verified persisted shape, not a version marker alone.
|
||||
//! Startup migrations compose case, tag, and assignment schemas without replacing applied
|
||||
//! migrations; request paths verify only and never acquire DDL authority.
|
||||
//!
|
||||
//! Legacy planning/ready/queued/inprogress become open; done/closed become closed with
|
||||
//! an explicit migration actor and unknown reason, never inferred success. Immutable
|
||||
//! snapshots retain the old phase, flags, routing, and resolution for downstream handoff,
|
||||
//! not a second current case authority. Existing tags, tombstones, memberships, receipts,
|
||||
//! judgments, and audit snapshots survive composition and are not regenerated on rerun.
|
||||
//! Assignment migration preserves identities and reconciled evidence; ambiguous or
|
||||
//! historical relationships remain diagnostic unknown handling, not fresh execution input.
|
||||
//! Migration never starts Workers or restores execution grants.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -7,23 +20,9 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
|
||||
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11;
|
||||
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Migration {
|
||||
version: i64,
|
||||
name: &'static str,
|
||||
apply: fn(&Connection) -> Result<()>,
|
||||
}
|
||||
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
name: BASELINE_MIGRATION_NAME,
|
||||
apply: create_latest_ticket_schema,
|
||||
}];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ExpectedColumn {
|
||||
name: &'static str,
|
||||
@@ -41,6 +40,9 @@ struct ExpectedForeignKey {
|
||||
}
|
||||
|
||||
const OWNED_TABLES: &[&str] = &[
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_labels",
|
||||
@@ -52,6 +54,9 @@ const OWNED_TABLES: &[&str] = &[
|
||||
"typed_ticket_relations",
|
||||
"typed_ticket_orchestration_plans",
|
||||
"typed_ticket_artifacts",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"workspace_resource_keys",
|
||||
];
|
||||
|
||||
@@ -96,6 +101,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("resolution", "TEXT", false, 0),
|
||||
];
|
||||
|
||||
const TAG_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("tag_id", "TEXT", true, 2),
|
||||
column("name", "TEXT", true, 0),
|
||||
column("normalized_name", "TEXT", true, 0),
|
||||
column("last_operation_key", "TEXT", true, 0),
|
||||
column("deleted", "INTEGER", true, 0),
|
||||
];
|
||||
const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("tag_id", "TEXT", true, 3),
|
||||
];
|
||||
const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("operation_key", "TEXT", true, 2),
|
||||
column("kind", "TEXT", true, 0),
|
||||
column("target_id", "TEXT", true, 0),
|
||||
column("input_json", "TEXT", true, 0),
|
||||
column("source_context_json", "TEXT", true, 0),
|
||||
column("before_json", "TEXT", true, 0),
|
||||
column("result_json", "TEXT", true, 0),
|
||||
column("at", "TEXT", true, 0),
|
||||
];
|
||||
const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[
|
||||
ExpectedForeignKey {
|
||||
from: "workspace_id",
|
||||
target_table: "typed_tickets",
|
||||
to: "workspace_id",
|
||||
on_delete: "CASCADE",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "ticket_id",
|
||||
target_table: "typed_tickets",
|
||||
to: "ticket_id",
|
||||
on_delete: "CASCADE",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "workspace_id",
|
||||
target_table: "workspace_tags",
|
||||
to: "workspace_id",
|
||||
on_delete: "RESTRICT",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "tag_id",
|
||||
target_table: "workspace_tags",
|
||||
to: "tag_id",
|
||||
on_delete: "RESTRICT",
|
||||
},
|
||||
];
|
||||
|
||||
const TARGET_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
@@ -254,66 +310,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(sqlite_err)?;
|
||||
|
||||
let result = (|| {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
let migration = MIGRATIONS
|
||||
.first()
|
||||
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?;
|
||||
(migration.apply)(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
migration.version,
|
||||
migration.name,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else if applied
|
||||
== BTreeMap::from([(
|
||||
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
BASELINE_MIGRATION_NAME.to_string(),
|
||||
)])
|
||||
{
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
|
||||
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
BASELINE_MIGRATION_NAME,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else {
|
||||
validate_applied_migrations(&applied)?;
|
||||
}
|
||||
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
})();
|
||||
|
||||
let result = migrate_sqlite_ticket_schema_in_transaction(connection);
|
||||
match result {
|
||||
Ok(()) => connection.execute_batch("COMMIT").map_err(sqlite_err),
|
||||
Err(error) => {
|
||||
@@ -323,8 +320,170 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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(
|
||||
"Ticket component migration requires a host transaction".into(),
|
||||
));
|
||||
}
|
||||
connection
|
||||
.execute_batch("SAVEPOINT ticket_component_composition")
|
||||
.map_err(sqlite_err)?;
|
||||
let result = (|| {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||
version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
create_latest_ticket_schema(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"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 = 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 {
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
|
||||
[],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
|
||||
verify_ticket_schema_shape(connection, shape)?;
|
||||
match shape {
|
||||
TicketSchemaShape::Legacy7 => {
|
||||
add_tag_schema(connection)?;
|
||||
migrate_legacy_cases(connection)?;
|
||||
}
|
||||
TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?,
|
||||
TicketSchemaShape::Cases9 => add_tag_schema(connection)?,
|
||||
TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {}
|
||||
}
|
||||
if version < 10 {
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
|
||||
params![chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.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)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => connection
|
||||
.execute_batch("RELEASE ticket_component_composition")
|
||||
.map_err(sqlite_err),
|
||||
Err(error) => {
|
||||
let _ = connection.execute_batch(
|
||||
"ROLLBACK TO ticket_component_composition; RELEASE ticket_component_composition",
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TicketSchemaShape {
|
||||
Legacy7,
|
||||
Tags8,
|
||||
Cases9,
|
||||
Composed10,
|
||||
Tracking11,
|
||||
}
|
||||
impl TicketSchemaShape {
|
||||
fn from_version(version: i64) -> Self {
|
||||
match version {
|
||||
7 => Self::Legacy7,
|
||||
8 => Self::Tags8,
|
||||
9 => Self::Cases9,
|
||||
10 => Self::Composed10,
|
||||
11 => Self::Tracking11,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
fn version(self) -> i64 {
|
||||
match self {
|
||||
Self::Legacy7 => 7,
|
||||
Self::Tags8 => 8,
|
||||
Self::Cases9 => 9,
|
||||
Self::Composed10 => 10,
|
||||
Self::Tracking11 => 11,
|
||||
}
|
||||
}
|
||||
fn tags(self) -> bool {
|
||||
matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11)
|
||||
}
|
||||
fn cases(self) -> bool {
|
||||
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::Tracking11)
|
||||
}
|
||||
|
||||
/// Retained Workspace schema-66 migration produces the pre-classification v7
|
||||
/// Ticket shape. Validate that intermediate contract before the normal startup
|
||||
/// path upgrades it; never require future tag tables inside that old transaction.
|
||||
pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Legacy7)
|
||||
}
|
||||
|
||||
/// Verifies retained six-state component 7 or tags-only component 8, without
|
||||
/// treating historical workflow values as the current two-state case authority.
|
||||
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
let version = applied.keys().next().copied().unwrap_or_default();
|
||||
if !matches!(version, 7 | 8) {
|
||||
return Err(TicketError::Sqlite(
|
||||
"legacy Ticket schema requires component 7 or 8".into(),
|
||||
));
|
||||
}
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version))
|
||||
}
|
||||
|
||||
fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) -> Result<()> {
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
collect_table_diagnostics(
|
||||
@@ -337,23 +496,40 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
|
||||
match load_applied_migrations(connection) {
|
||||
Ok(applied) => {
|
||||
if let Err(error) = validate_applied_migrations(&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!(
|
||||
"Ticket schema migration history must contain only the canonical version {} baseline marker",
|
||||
shape.version()
|
||||
)))
|
||||
};
|
||||
if let Err(error) = history {
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
} else if applied.len() != MIGRATIONS.len() {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!(
|
||||
"Ticket schema is not current: found {} migration(s), expected {}",
|
||||
applied.len(),
|
||||
MIGRATIONS.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => push_diagnostic(&mut diagnostics, error.to_string()),
|
||||
}
|
||||
|
||||
for (table, columns, foreign_keys) in [
|
||||
("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS),
|
||||
(
|
||||
"typed_ticket_tags",
|
||||
TICKET_TAG_COLUMNS,
|
||||
TICKET_TAG_FOREIGN_KEYS,
|
||||
),
|
||||
(
|
||||
"workspace_tag_operations",
|
||||
TAG_OPERATION_COLUMNS,
|
||||
TICKET_FOREIGN_KEYS,
|
||||
),
|
||||
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
|
||||
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
|
||||
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
|
||||
@@ -394,9 +570,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
CHILD_FOREIGN_KEYS,
|
||||
),
|
||||
] {
|
||||
if !shape.tags()
|
||||
&& matches!(
|
||||
table,
|
||||
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
|
||||
}
|
||||
|
||||
if shape.tags() {
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"workspace_tags",
|
||||
Some("workspace_tags_active_name"),
|
||||
true,
|
||||
&["workspace_id", "normalized_name"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_tags",
|
||||
Some("typed_ticket_tags_workspace_tag"),
|
||||
false,
|
||||
&["workspace_id", "tag_id", "ticket_id"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
let tag_name_index_sql: Option<String> = connection.query_row("SELECT sql FROM sqlite_master WHERE type='index' AND name='workspace_tags_active_name'", [], |row| row.get(0)).optional().map_err(sqlite_err)?;
|
||||
if !tag_name_index_sql.is_some_and(|sql| {
|
||||
sql.split_whitespace()
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
.ends_with("wheredeleted=0")
|
||||
}) {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
"workspace_tags_active_name must be partial WHERE deleted = 0".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_targets",
|
||||
@@ -448,10 +662,154 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
|
||||
}
|
||||
|
||||
if shape.cases() {
|
||||
for (table, columns) in [
|
||||
(
|
||||
"typed_ticket_case_metadata",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("closure_operation_key", "TEXT", false, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_case_history",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("ordinal", "INTEGER", true, 0),
|
||||
column("operation_key", "TEXT", true, 3),
|
||||
column("request_fingerprint", "TEXT", true, 0),
|
||||
column("judgment_json", "TEXT", true, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("snapshot_json", "TEXT", true, 0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false)
|
||||
{
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
}
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_case_history",
|
||||
None,
|
||||
true,
|
||||
&["workspace_id", "ticket_id", "ordinal"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
for name in [
|
||||
"typed_ticket_case_history_no_update",
|
||||
"typed_ticket_case_history_no_delete",
|
||||
"typed_ticket_legacy_workflow_no_update",
|
||||
"typed_ticket_legacy_workflow_no_delete",
|
||||
"typed_ticket_case_state_insert",
|
||||
"typed_ticket_case_state_update",
|
||||
] {
|
||||
let sql: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?;
|
||||
let canonical = include_str!("case_schema.sql")
|
||||
.split("CREATE TRIGGER ")
|
||||
.skip(1)
|
||||
.find(|ddl| ddl.starts_with(name))
|
||||
.expect("canonical case trigger")
|
||||
.split_once("END;")
|
||||
.expect("trigger terminator")
|
||||
.0;
|
||||
let canonical = format!("CREATE TRIGGER {canonical}END");
|
||||
let normalize = |sql: &str| {
|
||||
sql.trim_end_matches(';')
|
||||
.split_whitespace()
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
};
|
||||
match sql {
|
||||
None => push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!("missing Ticket case trigger {name}"),
|
||||
),
|
||||
Some(sql) if normalize(&sql) != normalize(&canonical) => push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!("invalid Ticket case trigger {name}"),
|
||||
),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for table in [
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
] {
|
||||
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
|
||||
}
|
||||
}
|
||||
if !shape.tags() {
|
||||
for table in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
] {
|
||||
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
|
||||
}
|
||||
}
|
||||
let invalid_states: i64 = connection.query_row(
|
||||
if shape.cases() {
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open','closed')"
|
||||
} else {
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('planning','ready','queued','inprogress','done','closed')"
|
||||
}, [], |row| row.get(0)).map_err(sqlite_err)?;
|
||||
if invalid_states != 0 {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!(
|
||||
"component {} has {invalid_states} unsupported Ticket states",
|
||||
shape.version()
|
||||
),
|
||||
);
|
||||
}
|
||||
for table in OWNED_TABLES {
|
||||
if !shape.tags()
|
||||
&& matches!(
|
||||
*table,
|
||||
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !shape.cases()
|
||||
&& matches!(
|
||||
*table,
|
||||
"typed_ticket_case_metadata"
|
||||
| "typed_ticket_case_history"
|
||||
| "typed_ticket_legacy_workflow_snapshot"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
@@ -471,7 +829,93 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
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.
|
||||
fn migrate_legacy_cases(connection: &Connection) -> Result<()> {
|
||||
let snapshots = {
|
||||
let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?;
|
||||
stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
crate::TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: row.get(2)?,
|
||||
workflow_state_explicit: row.get::<_, i64>(3)? != 0,
|
||||
status: row.get(4)?,
|
||||
queued_by: row.get(5)?,
|
||||
queued_at: row.get(6)?,
|
||||
resolution: row.get(7)?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?
|
||||
};
|
||||
// Validate old authority before any writes; unknown states are not silently repaired.
|
||||
for (_, _, snapshot) in &snapshots {
|
||||
if !matches!(
|
||||
snapshot.workflow_state.as_str(),
|
||||
"planning" | "ready" | "queued" | "inprogress" | "done" | "closed"
|
||||
) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"unsupported legacy Ticket workflow state {:?}",
|
||||
snapshot.workflow_state
|
||||
)));
|
||||
}
|
||||
}
|
||||
// State guards are installed only after all six-state rows have been converted.
|
||||
for (workspace_id, ticket_id, snapshot) in &snapshots {
|
||||
let state = if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
connection.execute("UPDATE typed_tickets SET workflow_state = ?3, status = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![workspace_id, ticket_id, state]).map_err(sqlite_err)?;
|
||||
}
|
||||
connection
|
||||
.execute_batch(include_str!("case_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
for (workspace_id, ticket_id, snapshot) in snapshots {
|
||||
connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
|
||||
let key = format!("migration-v10:{ticket_id}");
|
||||
let judgment = crate::TicketCaseHistoryEntry {
|
||||
operation_key: key.clone(),
|
||||
previous_closure_operation_key: None,
|
||||
// No typed evidence in schema7 establishes Completed. `done` is not success.
|
||||
action: crate::TicketCaseAction::Close {
|
||||
reason: crate::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: snapshot
|
||||
.resolution
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Migrated legacy {} state; closure reason is unknown.",
|
||||
snapshot.workflow_state
|
||||
)
|
||||
}),
|
||||
references: Vec::new(),
|
||||
author: "ticket-schema-migration-v10".into(),
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v10', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn add_tag_schema(connection: &Connection) -> Result<()> {
|
||||
// Use the exact canonical DDL rather than duplicating migration definitions.
|
||||
let (_, tag_schema) = include_str!("latest_schema.sql")
|
||||
.split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.")
|
||||
.expect("canonical tag schema marker");
|
||||
connection.execute_batch(tag_schema).map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
|
||||
@@ -636,9 +1080,15 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
|
||||
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
|
||||
let expected = BTreeMap::from([(
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
MIGRATIONS[0].name.to_string(),
|
||||
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!(
|
||||
@@ -1026,7 +1476,9 @@ mod tests {
|
||||
#[test]
|
||||
fn migrates_single_target_rows_to_ordered_read_write_targets() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
create_latest_ticket_schema(&connection).unwrap();
|
||||
connection
|
||||
.execute_batch(include_str!("../tests/fixtures/schema7.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"DROP TABLE typed_ticket_targets;
|
||||
@@ -1137,10 +1589,137 @@ mod tests {
|
||||
assert!(!migrated_attributes.contains_key("ref_selector"));
|
||||
assert_eq!(
|
||||
load_applied_migrations(&connection).unwrap(),
|
||||
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
|
||||
BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned())
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() {
|
||||
let upgraded = Connection::open_in_memory().unwrap();
|
||||
let legacy_sql = include_str!("../tests/fixtures/schema7.sql");
|
||||
upgraded.execute_batch(legacy_sql).unwrap();
|
||||
upgraded.execute_batch(
|
||||
"CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old');
|
||||
INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1);
|
||||
INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');"
|
||||
).unwrap();
|
||||
migrate_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
verify_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
let fresh = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&fresh).unwrap();
|
||||
let schema = |conn: &Connection| {
|
||||
let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap();
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?
|
||||
.map(|sql| sql.split_whitespace().collect::<String>()),
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(schema(&upgraded), schema(&fresh));
|
||||
let label: String = upgraded
|
||||
.query_row("SELECT label FROM typed_ticket_labels", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(label, " Legacy Label ");
|
||||
let tags: i64 = upgraded
|
||||
.query_row("SELECT COUNT(*) FROM workspace_tags", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0); // Legacy labels are never promoted to catalog tags.
|
||||
migrate_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
assert_eq!(schema(&upgraded), schema(&fresh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_schema_verification_detects_drift_without_repair() {
|
||||
for (drift, expected) in [
|
||||
("DROP TABLE typed_ticket_tags", "typed_ticket_tags"),
|
||||
(
|
||||
"ALTER TABLE workspace_tags RENAME COLUMN last_operation_key TO wrong_observed_key",
|
||||
"last_operation_key",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE workspace_tag_operations DROP COLUMN result_json",
|
||||
"result_json",
|
||||
),
|
||||
(
|
||||
"DROP INDEX typed_ticket_tags_workspace_tag",
|
||||
"typed_ticket_tags_workspace_tag",
|
||||
),
|
||||
(
|
||||
"DROP INDEX workspace_tags_active_name; CREATE UNIQUE INDEX workspace_tags_active_name ON workspace_tags(workspace_id, normalized_name)",
|
||||
"partial WHERE deleted = 0",
|
||||
),
|
||||
(
|
||||
"DROP TABLE typed_ticket_tags; CREATE TABLE typed_ticket_tags (workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,tag_id TEXT NOT NULL, PRIMARY KEY(workspace_id,ticket_id,tag_id)); CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id)",
|
||||
"missing foreign key",
|
||||
),
|
||||
] {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
connection.execute_batch(drift).unwrap();
|
||||
let error = verify_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected),
|
||||
"after {drift}: {error}"
|
||||
);
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_schema_rejects_cross_workspace_links_and_preserves_tombstones() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
connection.execute_batch(
|
||||
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1);
|
||||
INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);"
|
||||
).unwrap();
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
connection
|
||||
.execute("DELETE FROM workspace_tags WHERE workspace_id='one'", [])
|
||||
.is_err()
|
||||
);
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_future_migration_history_without_changing_schema() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
@@ -1155,7 +1734,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain only the canonical version 7 baseline marker"
|
||||
"migration history must contain only the canonical version 11 baseline marker"
|
||||
));
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1177,7 +1756,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain only the canonical version 7 baseline marker"
|
||||
"migration history must contain only the canonical version 11 baseline marker"
|
||||
));
|
||||
assert!(!table_exists(&connection, "typed_tickets").unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Workspace tag identity, display-name policy, and transactional SQLite authority.
|
||||
//!
|
||||
//! Display names use Rust `str::trim` (Unicode whitespace), then Unicode
|
||||
//! `str::to_lowercase` for active-name uniqueness. No NFC/NFKC normalization or
|
||||
//! full case folding is performed: canonically equivalent spellings can differ.
|
||||
//! Any control character in the input is rejected, including trimmed edges.
|
||||
//! The trimmed display name must be nonempty and at most 128 UTF-8 bytes.
|
||||
//! IDs are immutable and Workspace-scoped; names never grant execution authority.
|
||||
//! Rename preserves memberships. Delete atomically tombstones the identity and removes
|
||||
//! current memberships, retaining historical identity/name snapshots. Reusing a deleted
|
||||
//! name creates a new ID. Legacy labels are not classification authority.
|
||||
//!
|
||||
//! Rename/delete compare the observed tag operation key; membership replacement compares
|
||||
//! the full active ID set, not a content digest or timestamp. Returning to the same set is
|
||||
//! intentionally equivalent; an observed deleted ID is stale. Delete and attachment share
|
||||
//! an immediate transaction, so missing, deleted, or foreign identities cannot dangle.
|
||||
//!
|
||||
//! Exact replay compares full input, target, and authenticated identity before live checks,
|
||||
//! returning the saved response without another audit event. It is not fresh observation:
|
||||
//! reread before a new edit, and retain the original key/input after an uncertain outcome.
|
||||
//! A no-op set saves a receipt without a tags-changed event. Tag edits change neither
|
||||
//! editable requirements nor case judgments, handling, grants, routing, or MR evidence.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tags_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Workspace-scoped immutable identity with a mutable display name.
|
||||
/// Names are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and
|
||||
/// deduplicated among active tags using Rust Unicode lowercase (not full case
|
||||
/// folding or NFC/NFKC). Empty names and any input control characters are invalid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct WorkspaceTag {
|
||||
pub workspace_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub last_operation_key: String,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// Active tags in ascending ID order, strictly after `after`; default/max 200.
|
||||
/// Larger limits are clamped to 200; zero returns an empty page.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TagListRequest {
|
||||
pub after: Option<String>,
|
||||
#[schemars(range(min = 0, max = 200))]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceTagMutation {
|
||||
pub operation_key: String,
|
||||
pub action: WorkspaceTagAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkspaceTagAction {
|
||||
Create {
|
||||
name: String,
|
||||
},
|
||||
Rename {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Compare entire active ID set, then replace it atomically. IDs must be unique;
|
||||
/// input order is immaterial to CAS, but remains significant for exact replay.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketTagSetRequest {
|
||||
pub expected_tag_ids: Vec<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
fn normalize_name(input: &str) -> Result<(String, String)> {
|
||||
let name = input.trim();
|
||||
if name.is_empty() || name.len() > 128 || input.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict("tag name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
|
||||
}
|
||||
Ok((name.to_owned(), name.to_lowercase()))
|
||||
}
|
||||
|
||||
fn sorted_ids(ids: &[String]) -> Result<Vec<String>> {
|
||||
if ids.len() > 100 || ids.iter().any(|id| id.len() > 128) {
|
||||
return Err(TicketError::Conflict(
|
||||
"tag sets require at most 100 IDs of at most 128 bytes".into(),
|
||||
));
|
||||
}
|
||||
let mut sorted = ids.to_vec();
|
||||
sorted.sort();
|
||||
if sorted.iter().any(|id| id.trim().is_empty())
|
||||
|| sorted.windows(2).any(|pair| pair[0] == pair[1])
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tag IDs must be nonempty and unique".into(),
|
||||
));
|
||||
}
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
fn read_tag(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceTag> {
|
||||
Ok(WorkspaceTag {
|
||||
workspace_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
last_operation_key: row.get(3)?,
|
||||
deleted: row.get::<_, i64>(4)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
impl SqliteTicketBackend {
|
||||
pub(crate) fn load_ticket_tags(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<WorkspaceTag>> {
|
||||
let mut statement = conn.prepare("SELECT tag.workspace_id,tag.tag_id,tag.name,tag.last_operation_key,tag.deleted FROM workspace_tags AS tag JOIN typed_ticket_tags AS link ON link.workspace_id=tag.workspace_id AND link.tag_id=tag.tag_id WHERE link.workspace_id=?1 AND link.ticket_id=?2 AND tag.deleted=0 ORDER BY tag.tag_id").map_err(sqlite_err)?;
|
||||
statement
|
||||
.query_map(params![self.workspace_id, ticket_id], read_tag)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn load_tag(&self, conn: &Connection, id: &str) -> Result<WorkspaceTag> {
|
||||
conn.query_row("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2", params![self.workspace_id,id], read_tag).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(format!("workspace tag {id}")))
|
||||
}
|
||||
|
||||
fn tag_input(&self, kind: &str, target: &str, request: &impl Serialize) -> Result<String> {
|
||||
// Bind authenticated identity, not its mutable assignment/role context:
|
||||
// a receipt is still the same request after the Worker is reassigned.
|
||||
// The complete original context is retained separately in the audit row.
|
||||
let identity: BTreeMap<_, _> = self
|
||||
.event_attributes
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"source_account_id"
|
||||
| "source_user_id"
|
||||
| "source_runtime_id"
|
||||
| "source_worker_id"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&(kind, target, request, identity)).map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn replay_tag_operation<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
input: &str,
|
||||
) -> Result<Option<T>> {
|
||||
validate_required_event_value("operation_key", key)?;
|
||||
let receipt: Option<(String,String)> = conn.query_row("SELECT input_json,result_json FROM workspace_tag_operations WHERE workspace_id=?1 AND operation_key=?2",params![self.workspace_id,key],|row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(sqlite_err)?;
|
||||
match receipt {
|
||||
Some((original, result)) if original == input => {
|
||||
Ok(Some(serde_json::from_str(&result).map_err(sqlite_err)?))
|
||||
}
|
||||
Some(_) => Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: key.to_owned(),
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tag_operation(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
kind: &str,
|
||||
target: &str,
|
||||
input: &str,
|
||||
snapshots: (&impl Serialize, &impl Serialize),
|
||||
) -> Result<()> {
|
||||
conn.execute("INSERT INTO workspace_tag_operations (workspace_id,operation_key,kind,target_id,input_json,before_json,result_json,at,source_context_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![self.workspace_id,key,kind,target,input,serde_json::to_string(snapshots.0).map_err(sqlite_err)?,serde_json::to_string(snapshots.1).map_err(sqlite_err)?,now_utc(),serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_ticket_tags(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ticket_id: &str,
|
||||
key: &str,
|
||||
before: &[WorkspaceTag],
|
||||
after: &[WorkspaceTag],
|
||||
) -> Result<()> {
|
||||
self.insert_event(
|
||||
conn,
|
||||
ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::TagsChanged,
|
||||
author: Some(default_author()),
|
||||
at: Some(now_utc()),
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some("Tags changed".into()),
|
||||
body: MarkdownText::new("Ticket tags changed"),
|
||||
references: Vec::new(),
|
||||
attributes: BTreeMap::from([
|
||||
("operation_key".into(), key.to_owned()),
|
||||
(
|
||||
"tags_before".into(),
|
||||
serde_json::to_string(before).map_err(sqlite_err)?,
|
||||
),
|
||||
(
|
||||
"tags_after".into(),
|
||||
serde_json::to_string(after).map_err(sqlite_err)?,
|
||||
),
|
||||
]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
|
||||
self.with_read(|conn| {
|
||||
let mut statement = conn.prepare("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND deleted=0 AND (?2 IS NULL OR tag_id>?2) ORDER BY tag_id LIMIT ?3").map_err(sqlite_err)?;
|
||||
statement.query_map(params![self.workspace_id,request.after,request.limit.unwrap_or(200).min(200) as i64],read_tag).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
|
||||
self.with_write(|conn| {
|
||||
let input = self.tag_input("mutate_workspace_tag", "", &request)?;
|
||||
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
|
||||
let (before, result, kind) = match &request.action {
|
||||
WorkspaceTagAction::Create { name } => {
|
||||
let (name, normalized) = normalize_name(name)?;
|
||||
self.require_unique_tag_name(conn,&normalized,None)?;
|
||||
let millis = unix_epoch_millis_now().map_err(|error| TicketError::Conflict(format!("failed to read tag ID timestamp: {error}")))?;
|
||||
let id = allocate_record_id(millis, |candidate| {
|
||||
conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,candidate],|_| Ok(())).optional().map(|row| row.is_some()).unwrap_or(true)
|
||||
}).map_err(|error| TicketError::Conflict(error.to_string()))?;
|
||||
let tag = WorkspaceTag { workspace_id: self.workspace_id.clone(), id, name, last_operation_key: request.operation_key.clone(), deleted: false };
|
||||
conn.execute("INSERT INTO workspace_tags (workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES (?1,?2,?3,?4,?5,0)",params![tag.workspace_id,tag.id,tag.name,normalized,tag.last_operation_key]).map_err(sqlite_err)?;
|
||||
(None,tag,"create")
|
||||
}
|
||||
WorkspaceTagAction::Rename { tag_id,expected_last_operation_key,.. } | WorkspaceTagAction::Delete { tag_id,expected_last_operation_key } => {
|
||||
let previous = self.load_tag(conn,tag_id)?;
|
||||
if previous.deleted || &previous.last_operation_key != expected_last_operation_key {
|
||||
return Err(TicketError::Conflict(format!("stale or deleted tag {tag_id}: expected operation {expected_last_operation_key:?}, actual {:?}",previous.last_operation_key)));
|
||||
}
|
||||
let mut tag = previous.clone();
|
||||
tag.last_operation_key = request.operation_key.clone();
|
||||
let kind = if let WorkspaceTagAction::Rename { name,.. } = &request.action {
|
||||
let (name,normalized) = normalize_name(name)?;
|
||||
self.require_unique_tag_name(conn,&normalized,Some(tag_id))?;
|
||||
tag.name = name;
|
||||
conn.execute("UPDATE workspace_tags SET name=?3,normalized_name=?4,last_operation_key=?5 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,tag.name,normalized,request.operation_key]).map_err(sqlite_err)?;
|
||||
"rename"
|
||||
} else {
|
||||
// Capture active snapshots before tombstoning; links and audit are
|
||||
// cleared/written under the same immediate lock as attachments.
|
||||
let ticket_ids = {
|
||||
let mut stmt = conn.prepare("SELECT ticket_id FROM typed_ticket_tags WHERE workspace_id=?1 AND tag_id=?2 ORDER BY ticket_id").map_err(sqlite_err)?;
|
||||
stmt.query_map(params![self.workspace_id,tag_id],|row| row.get::<_,String>(0)).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?
|
||||
};
|
||||
for ticket_id in ticket_ids {
|
||||
let before = self.load_ticket_tags(conn,&ticket_id)?;
|
||||
let after: Vec<_> = before.iter().filter(|tag| &tag.id != tag_id).cloned().collect();
|
||||
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2 AND tag_id=?3",params![self.workspace_id,ticket_id,tag_id]).map_err(sqlite_err)?;
|
||||
self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?;
|
||||
}
|
||||
tag.deleted = true;
|
||||
conn.execute("UPDATE workspace_tags SET deleted=1,last_operation_key=?3 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,request.operation_key]).map_err(sqlite_err)?;
|
||||
"delete"
|
||||
};
|
||||
(Some(previous),tag,kind)
|
||||
}
|
||||
};
|
||||
self.record_tag_operation(conn,&request.operation_key,kind,&result.id,&input,(&before,&result))?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn require_unique_tag_name(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
normalized: &str,
|
||||
except: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let duplicate = conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND normalized_name=?2 AND deleted=0 AND (?3 IS NULL OR tag_id<>?3)",params![self.workspace_id,normalized,except],|_| Ok(())).optional().map_err(sqlite_err)?.is_some();
|
||||
if duplicate {
|
||||
return Err(TicketError::Conflict(
|
||||
"active workspace tag name already exists".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: TicketTagSetRequest,
|
||||
) -> Result<Vec<WorkspaceTag>> {
|
||||
self.with_write(|conn| {
|
||||
// Preserve exact selector input, not just its resolved identity.
|
||||
let input = self.tag_input("set_ticket_tags", "", &(&id,&request))?;
|
||||
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
|
||||
let ticket_id = self.resolve_ticket_id(conn,id)?;
|
||||
self.require_active_source_assignment(conn,&ticket_id)?;
|
||||
let before = self.load_ticket_tags(conn,&ticket_id)?;
|
||||
let expected = sorted_ids(&request.expected_tag_ids)?;
|
||||
let desired = sorted_ids(&request.tag_ids)?;
|
||||
if before.iter().map(|tag| tag.id.clone()).collect::<Vec<_>>() != expected {
|
||||
return Err(TicketError::Conflict("stale Ticket tag set".into()));
|
||||
}
|
||||
let after = desired.iter().map(|id| {
|
||||
let tag = self.load_tag(conn,id)?;
|
||||
if tag.deleted { return Err(TicketError::Conflict(format!("workspace tag {id} is deleted"))); }
|
||||
Ok(tag)
|
||||
}).collect::<Result<Vec<_>>>()?;
|
||||
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2",params![self.workspace_id,ticket_id]).map_err(sqlite_err)?;
|
||||
for tag in &after {
|
||||
conn.execute("INSERT INTO typed_ticket_tags (workspace_id,ticket_id,tag_id) VALUES (?1,?2,?3)",params![self.workspace_id,ticket_id,tag.id]).map_err(sqlite_err)?;
|
||||
}
|
||||
if before != after { self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; }
|
||||
self.record_tag_operation(conn,&request.operation_key,"set_ticket_tags",&ticket_id,&input,(&before,&after))?;
|
||||
Ok(after)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() },
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn rename_tag(
|
||||
backend: &SqliteTicketBackend,
|
||||
key: &str,
|
||||
tag: &WorkspaceTag,
|
||||
name: &str,
|
||||
) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Rename {
|
||||
tag_id: tag.id.clone(),
|
||||
expected_last_operation_key: tag.last_operation_key.clone(),
|
||||
name: name.into(),
|
||||
},
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation {
|
||||
WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Delete {
|
||||
tag_id: tag.id.clone(),
|
||||
expected_last_operation_key: tag.last_operation_key.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn set_request(
|
||||
key: &str,
|
||||
expected: &[WorkspaceTag],
|
||||
desired: &[WorkspaceTag],
|
||||
) -> TicketTagSetRequest {
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: desired.iter().map(|t| t.id.clone()).collect(),
|
||||
operation_key: key.into(),
|
||||
}
|
||||
}
|
||||
fn audit_count(backend: &SqliteTicketBackend) -> i64 {
|
||||
backend
|
||||
.open_connection()
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
|
||||
params![backend.workspace_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}");
|
||||
assert_eq!(tag.name, "Ä BC");
|
||||
for name in ["ä bc", " Ä BC "] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "duplicate".into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() }
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for name in [
|
||||
"",
|
||||
" \u{2003}",
|
||||
"bad\nname",
|
||||
"\tname",
|
||||
"a\0",
|
||||
&"é".repeat(65),
|
||||
] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "invalid".into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() }
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
create_tag(&backend, "boundary", &"é".repeat(64));
|
||||
create_tag(&backend, "sharp", "ß");
|
||||
create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding.
|
||||
create_tag(&backend, "composed", "é");
|
||||
create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization.
|
||||
assert_eq!(audit_count(&backend), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let a = create_tag(&backend, "a", "A");
|
||||
let b = create_tag(&backend, "b", "B");
|
||||
let renamed = rename_tag(&backend, "rename", &a, "New");
|
||||
assert_eq!(a.id, renamed.id);
|
||||
for action in [
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: a.id.clone(),
|
||||
expected_last_operation_key: "a".into(),
|
||||
name: "Stale".into(),
|
||||
},
|
||||
WorkspaceTagAction::Delete {
|
||||
tag_id: a.id.clone(),
|
||||
expected_last_operation_key: "a".into(),
|
||||
},
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
name: " b ".into(),
|
||||
},
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
name: "\n".into(),
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "rejected".into(),
|
||||
action
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
let active = backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap();
|
||||
assert!(active.contains(&renamed) && active.contains(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = SqliteTicketBackend::open(&db, "ws")
|
||||
.unwrap()
|
||||
.with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-a".into(),
|
||||
)]));
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create { name: " A ".into() },
|
||||
};
|
||||
let original = backend.mutate_workspace_tag(request.clone()).unwrap();
|
||||
let renamed = rename_tag(&backend, "rename", &original, "B");
|
||||
let deletion = delete_request("delete", &renamed);
|
||||
let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap();
|
||||
assert!(tombstone.deleted);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(request.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(deletion.clone()).unwrap(),
|
||||
tombstone
|
||||
);
|
||||
let restarted = SqliteTicketBackend::open_verified(&db, "ws")
|
||||
.unwrap()
|
||||
.with_event_attributes(backend.event_attributes.clone());
|
||||
assert_eq!(
|
||||
restarted.mutate_workspace_tag(request.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
let other_actor = restarted.with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-b".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_actor.mutate_workspace_tag(request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
backend.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create { name: "A".into() }
|
||||
}),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Old name");
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..2 {
|
||||
let id = backend
|
||||
.create(NewTicket::new(format!("ticket {i}")))
|
||||
.unwrap()
|
||||
.id;
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)),
|
||||
)
|
||||
.unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let renamed = rename_tag(&backend, "rename", &tag, "New name");
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &renamed))
|
||||
.unwrap();
|
||||
let replacement = create_tag(&backend, "recreate", "new NAME");
|
||||
assert_ne!(replacement.id, tag.id);
|
||||
for id in ids {
|
||||
let ticket = backend.show(id.into()).unwrap();
|
||||
assert!(ticket.tags.is_empty());
|
||||
let events: Vec<_> = ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.kind == TicketEventKind::TagsChanged)
|
||||
.collect();
|
||||
assert_eq!(events.len(), 2);
|
||||
let first: Vec<WorkspaceTag> =
|
||||
serde_json::from_str(&events[0].attributes["tags_after"]).unwrap();
|
||||
assert_eq!(first, vec![tag.clone()]);
|
||||
let before_delete: Vec<WorkspaceTag> =
|
||||
serde_json::from_str(&events[1].attributes["tags_before"]).unwrap();
|
||||
assert_eq!(before_delete, vec![renamed.clone()]);
|
||||
assert_eq!(events[1].attributes["tags_after"], "[]");
|
||||
}
|
||||
let conn = backend.open_connection().unwrap();
|
||||
let deleted: i64 = conn
|
||||
.query_row(
|
||||
"SELECT deleted FROM workspace_tags WHERE workspace_id='ws' AND tag_id=?1",
|
||||
params![tag.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
assert_eq!(audit_count(&backend), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_tag_set_compares_whole_set_validates_ids_and_replays_historical_result() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let a = create_tag(&backend, "a", "A");
|
||||
let b = create_tag(&backend, "b", "B");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let request = set_request("set", &[], &[b.clone(), a.clone()]);
|
||||
let stored = backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap();
|
||||
assert!(stored.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||
for bad in [
|
||||
set_request("bad", std::slice::from_ref(&a), &[]),
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec![a.id.clone(), a.id.clone()],
|
||||
operation_key: "bad".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec!["missing".into()],
|
||||
operation_key: "bad".into(),
|
||||
},
|
||||
] {
|
||||
assert!(backend.set_ticket_tags(id.clone().into(), bad).is_err());
|
||||
}
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
|
||||
let renamed = rename_tag(&backend, "rename", &a, "Renamed");
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &renamed))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap(),
|
||||
stored
|
||||
);
|
||||
let mut changed = request.clone();
|
||||
changed.tag_ids.reverse();
|
||||
assert!(matches!(
|
||||
backend.set_ticket_tags(id.clone().into(), changed),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
let current = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(current.tags, vec![b]);
|
||||
assert_eq!(
|
||||
current
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.kind == TicketEventKind::TagsChanged)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let other_actor = backend.clone().with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-b".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_actor.set_ticket_tags(id.into(), request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(audit_count(&backend), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_and_operation_keys_are_workspace_scoped() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let one = SqliteTicketBackend::open(&db, "one").unwrap();
|
||||
let two = SqliteTicketBackend::open(&db, "two").unwrap();
|
||||
let a = create_tag(&one, "same-key", "Same name");
|
||||
let b = create_tag(&two, "same-key", "Same name");
|
||||
let ticket = two.create(NewTicket::new("ticket")).unwrap().id;
|
||||
assert!(
|
||||
two.mutate_workspace_tag(delete_request("delete", &a))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
two.set_ticket_tags(ticket.clone().into(), set_request("attach", &[], &[a]))
|
||||
.is_err()
|
||||
);
|
||||
two.set_ticket_tags(
|
||||
ticket.into(),
|
||||
set_request("attach", &[], std::slice::from_ref(&b)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
one.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
two.list_workspace_tags(TagListRequest::default()).unwrap(),
|
||||
vec![b]
|
||||
);
|
||||
assert_eq!(audit_count(&one), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_do_not_change_content_workflow_queue_targets_or_legacy_labels_in_any_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "tag", "open");
|
||||
let closed_tag = create_tag(&backend, "closed-tag", "closed");
|
||||
for (i, state) in [TicketState::Open, TicketState::Closed]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut new = NewTicket::new(format!("ticket {i}"));
|
||||
new.workflow_state = Some(state);
|
||||
new.labels = vec!["legacy label".into()];
|
||||
new.queued_by = Some("user".into());
|
||||
new.queued_at = Some("yesterday".into());
|
||||
let id = backend.create(new).unwrap().id;
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request(&format!("set-{i}"), &[], &[tag.clone(), closed_tag.clone()]),
|
||||
)
|
||||
.unwrap();
|
||||
let after = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(before.meta, after.meta);
|
||||
assert_eq!(before.document, after.document);
|
||||
assert_eq!(before.resolution, after.resolution);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&before),
|
||||
ticket_content_digest(&after)
|
||||
);
|
||||
assert_eq!(before.closure_operation_key, after.closure_operation_key);
|
||||
assert_eq!(before.current_closure, after.current_closure);
|
||||
assert_eq!(before.case_history, after.case_history);
|
||||
assert_eq!(
|
||||
before.legacy_workflow_snapshot,
|
||||
after.legacy_workflow_snapshot
|
||||
);
|
||||
let mut expected_tags = vec![tag.clone(), closed_tag.clone()];
|
||||
expected_tags.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
assert_eq!(after.tags, expected_tags);
|
||||
let summary = backend
|
||||
.list(TicketListQuery::all())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(summary.tags, after.tags);
|
||||
let projection = backend.list_workspace_projection(200).unwrap();
|
||||
assert_eq!(
|
||||
projection
|
||||
.items
|
||||
.iter()
|
||||
.find(|s| s.summary.id == id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.tags,
|
||||
after.tags
|
||||
);
|
||||
let page = backend
|
||||
.list_workspace_projection_page(SqliteTicketListPageQuery {
|
||||
limit: 200,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
page.items
|
||||
.iter()
|
||||
.find(|s| s.summary.id == id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.tags,
|
||||
after.tags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
// Fixture insertion avoids spending this query test on 205 mutation transactions.
|
||||
backend
|
||||
.with_write(|conn| {
|
||||
for i in 0..205 {
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)",
|
||||
params![format!("tag-{i:03}")],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let first = backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(usize::MAX),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(first.len(), 200);
|
||||
assert_eq!(first[0].id, "tag-000");
|
||||
assert_eq!(first[199].id, "tag-199");
|
||||
assert_eq!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap(),
|
||||
first
|
||||
);
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &first[0]))
|
||||
.unwrap();
|
||||
let tail = backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: Some("tag-199".into()),
|
||||
limit: Some(200),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tail.len(), 5);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(0)
|
||||
})
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(1)
|
||||
})
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|t| t.id == "tag-001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Tag");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("test hook".into()))
|
||||
}));
|
||||
let attach = set_request("attach", &[], std::slice::from_ref(&tag));
|
||||
assert!(
|
||||
failing
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(backend.show(id.clone().into()).unwrap().tags.is_empty());
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
backend.set_ticket_tags(id.clone().into(), attach).unwrap();
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
let deletion = delete_request("delete", &tag);
|
||||
assert!(failing.mutate_workspace_tag(deletion.clone()).is_err());
|
||||
assert_eq!(backend.show(id.into()).unwrap(), before);
|
||||
assert_eq!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap(),
|
||||
vec![tag]
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 2);
|
||||
backend.mutate_workspace_tag(deletion).unwrap();
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_tag_replay_and_delete_attach_are_serialized() {
|
||||
use std::sync::Barrier;
|
||||
use std::thread;
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = SqliteTicketBackend::open(&db, "ws").unwrap();
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut joins = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
joins.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
create_tag(&backend, "create", "Tag")
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
let tag = joins.remove(0).join().unwrap();
|
||||
assert_eq!(joins.remove(0).join().unwrap(), tag);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let attach = {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
let tag = tag.clone();
|
||||
let id = id.clone();
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag]))
|
||||
})
|
||||
};
|
||||
let delete = {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
let tag = tag.clone();
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
backend.mutate_workspace_tag(delete_request("delete", &tag))
|
||||
})
|
||||
};
|
||||
barrier.wait();
|
||||
let attached = attach.join().unwrap();
|
||||
assert!(delete.join().unwrap().unwrap().deleted);
|
||||
assert!(backend.show(id.into()).unwrap().tags.is_empty());
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 });
|
||||
let links: i64 = backend
|
||||
.open_connection()
|
||||
.unwrap()
|
||||
.query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(links, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Tag");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let request = set_request("attach", &[], std::slice::from_ref(&tag));
|
||||
let mut reused = request.clone();
|
||||
reused.operation_key = "create".into();
|
||||
assert!(matches!(
|
||||
backend.set_ticket_tags(id.clone().into(), reused),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
let conn = backend.open_connection().unwrap();
|
||||
conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap();
|
||||
conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap();
|
||||
let worker = backend.clone().with_event_attributes(BTreeMap::from([
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_assignment_id".into(), "assignment".into()),
|
||||
]));
|
||||
let stored = worker
|
||||
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM ticket_active_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
worker
|
||||
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
|
||||
.unwrap(),
|
||||
stored
|
||||
);
|
||||
assert!(matches!(
|
||||
worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(
|
||||
worker
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request("new-operation", &stored, &[])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &tag))
|
||||
.unwrap();
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.into(),
|
||||
set_request("deleted-attach", &[], std::slice::from_ref(&tag))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_domain_dispatch_and_wire_contracts_are_exact() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "Name".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&request).unwrap(),
|
||||
serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}})
|
||||
);
|
||||
let operation = TicketBackendOperation::MutateWorkspaceTag { request };
|
||||
let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
|
||||
let TicketBackendOperationResult::WorkspaceTag(tag) =
|
||||
execute_ticket_backend_operation(&backend, operation).unwrap()
|
||||
else {
|
||||
panic!("tag result")
|
||||
};
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let result = execute_ticket_backend_operation(
|
||||
&backend,
|
||||
TicketBackendOperation::SetTicketTags {
|
||||
id: id.clone().into(),
|
||||
request: set_request("set", &[], std::slice::from_ref(&tag)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()])
|
||||
);
|
||||
let result = execute_ticket_backend_operation(
|
||||
&backend,
|
||||
TicketBackendOperation::ListWorkspaceTags {
|
||||
request: TagListRequest::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag]));
|
||||
let ticket = backend.show(id.into()).unwrap();
|
||||
let mut wire = serde_json::to_value(&ticket).unwrap();
|
||||
wire.as_object_mut().unwrap().remove("tags");
|
||||
assert!(
|
||||
serde_json::from_value::<Ticket>(wire)
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
|
||||
let mut wire = serde_json::to_value(summary).unwrap();
|
||||
wire.as_object_mut().unwrap().remove("tags");
|
||||
assert!(
|
||||
serde_json::from_value::<TicketSummary>(wire)
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
let schema = schemars::schema_for!(WorkspaceTagAction);
|
||||
assert!(
|
||||
serde_json::to_string(&schema)
|
||||
.unwrap()
|
||||
.contains("expected_last_operation_key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_tag_set_records_receipt_without_claiming_a_membership_change() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
let request = set_request("noop", &[], &[]);
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
backend.show(id.clone().into()).unwrap().events,
|
||||
before.events
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
for request in [
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec!["x".into(); 101],
|
||||
tag_ids: vec![],
|
||||
operation_key: "large-observation".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: (0..101).map(|i| format!("tag-{i}")).collect(),
|
||||
operation_key: "large-desired".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["x".repeat(129)],
|
||||
operation_key: "large-id".into(),
|
||||
},
|
||||
] {
|
||||
assert!(backend.set_ticket_tags(id.clone().into(), request).is_err());
|
||||
assert_eq!(
|
||||
backend.show(id.clone().into()).unwrap().events,
|
||||
before.events
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_receipt_preserves_original_context_but_reassignment_does_not_change_request_identity() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let original_context = BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_assignment_id".into(), "assignment-a".into()),
|
||||
]);
|
||||
let original = backend
|
||||
.clone()
|
||||
.with_event_attributes(original_context.clone());
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "ready".into(),
|
||||
},
|
||||
};
|
||||
let tag = original.mutate_workspace_tag(request.clone()).unwrap();
|
||||
let reassigned = backend.clone().with_event_attributes(BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_actor_role".into(), "contributor".into()),
|
||||
]));
|
||||
assert_eq!(reassigned.mutate_workspace_tag(request).unwrap(), tag);
|
||||
let stored: String = backend.with_read(|conn| conn.query_row("SELECT source_context_json FROM workspace_tag_operations WHERE workspace_id='ws' AND operation_key='create'", [], |row| row.get(0)).map_err(sqlite_err)).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<BTreeMap<String, String>>(&stored).unwrap(),
|
||||
original_context
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
}
|
||||
+271
-618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,800 @@
|
||||
//! Public Ticket case authority contracts over isolated SQLite stores.
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn backend(temp: &TempDir) -> SqliteTicketBackend {
|
||||
SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap()
|
||||
}
|
||||
fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(),
|
||||
references: vec![TicketReference {
|
||||
kind: "result".into(),
|
||||
target: "document:example".into(),
|
||||
}],
|
||||
author: Some("human:alice".into()),
|
||||
}
|
||||
}
|
||||
fn close(reason: TicketClosureReason) -> TicketCaseAction {
|
||||
TicketCaseAction::Close { reason }
|
||||
}
|
||||
fn create(backend: &SqliteTicketBackend) -> Ticket {
|
||||
let mut input = NewTicket::new("Independent case");
|
||||
input.assignee = Some("historical-assignee".into());
|
||||
backend.create_with_snapshot(input).unwrap().1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(closed.meta.targets.is_empty());
|
||||
assert_eq!(closed.meta.workflow_state, TicketState::Closed);
|
||||
let first_judgment = closed.current_closure.clone().unwrap();
|
||||
assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned);
|
||||
assert_eq!(first_judgment.author, "human:alice");
|
||||
assert_eq!(
|
||||
first_judgment.references,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)).references
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Duplicate,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
corrected.current_closure.as_ref().unwrap().reason,
|
||||
TicketClosureReason::Duplicate
|
||||
);
|
||||
assert_eq!(corrected.case_history[0], closed.case_history[0]);
|
||||
assert_eq!(
|
||||
corrected.case_history[1]
|
||||
.previous_closure_operation_key
|
||||
.as_deref(),
|
||||
Some("close")
|
||||
);
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert!(reopened.resolution.is_none());
|
||||
assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct"));
|
||||
assert_eq!(reopened.case_history.len(), 3);
|
||||
assert_eq!(reopened.meta.assignee, initial.meta.assignee);
|
||||
assert_eq!(reopened.meta.resource_key, initial.meta.resource_key);
|
||||
assert_eq!(
|
||||
&reopened.events[..initial.events.len()],
|
||||
initial.events.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
for reason in [
|
||||
TicketClosureReason::Completed,
|
||||
TicketClosureReason::NotPlanned,
|
||||
TicketClosureReason::Duplicate,
|
||||
TicketClosureReason::Unknown,
|
||||
TicketClosureReason::Other("Custom decision".into()),
|
||||
] {
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(reason.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(closed.current_closure.unwrap().reason, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_content_digest = "stale".into();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleContent { .. })
|
||||
));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_state = TicketState::Closed;
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleWorkflowState { .. })
|
||||
));
|
||||
let mut stale = base;
|
||||
stale.expected_closure_operation_key = Some("unobserved".into());
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_keys_protect_closed_correction_and_open_closed_open_aba() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let stale_open = request(
|
||||
&initial,
|
||||
"late-close",
|
||||
close(TicketClosureReason::Completed),
|
||||
);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
let stale_correction = request(
|
||||
&closed,
|
||||
"late-correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::NotPlanned,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_correction),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ticket_content_digest(&reopened),
|
||||
ticket_content_digest(&initial)
|
||||
);
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_open),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("Changed requirements".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edited.case_history, reopened.case_history);
|
||||
let restored =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap();
|
||||
assert_eq!(
|
||||
restored.update_case(&initial.meta.id, operation).unwrap(),
|
||||
edited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let mut changed = Vec::new();
|
||||
let mut x = operation.clone();
|
||||
x.expected_content_digest.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_state = TicketState::Closed;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_closure_operation_key = Some("close".into());
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = close(TicketClosureReason::NotPlanned);
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = TicketCaseAction::Reopen;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.explanation.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.references.clear();
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.author = None;
|
||||
changed.push(x);
|
||||
for input in changed {
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, input),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
}
|
||||
let other_source = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"source_runtime_id".into(),
|
||||
"other-runtime".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_source.update_case(&initial.meta.id, operation),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for action in [
|
||||
TicketCaseAction::Reopen,
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(&initial.meta.id, request(&initial, "invalid", action))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"double-close",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for reason in [
|
||||
"".into(),
|
||||
" \n".into(),
|
||||
"bad\0reason".into(),
|
||||
"あ".repeat(342),
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "bad", close(TicketClosureReason::Other(reason)))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut valid = request(
|
||||
&initial,
|
||||
"close",
|
||||
close(TicketClosureReason::Other("x".repeat(1024))),
|
||||
);
|
||||
valid.explanation = format!(
|
||||
"## Decision\n{}\n<!-- typed SQLite text -->",
|
||||
"long".repeat(8000)
|
||||
);
|
||||
let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap();
|
||||
assert_eq!(
|
||||
closed.current_closure.as_ref().unwrap().explanation,
|
||||
valid.explanation
|
||||
);
|
||||
assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "key", close(TicketClosureReason::Unknown));
|
||||
let mut cases = Vec::new();
|
||||
let mut x = base.clone();
|
||||
x.explanation = " ".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.explanation = "bad\0text".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.author = Some(" ".into());
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].target.clear();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].kind.clear();
|
||||
cases.push(x);
|
||||
let mut x = base;
|
||||
x.operation_key.clear();
|
||||
cases.push(x);
|
||||
for input in cases {
|
||||
assert!(store.update_case(&initial.meta.id, input).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("hook rejected".into()))
|
||||
}));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
assert!(
|
||||
rejected
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let closed = store.update_case(&initial.meta.id, operation).unwrap();
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
for sql in [
|
||||
"UPDATE typed_ticket_case_history SET judgment_json = '{}'",
|
||||
"DELETE FROM typed_ticket_case_history",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut joins = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = operation.clone();
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
for join in joins {
|
||||
join.join().unwrap().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let mut joins = Vec::new();
|
||||
for key in ["winner-a", "winner-b"] {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = request(&initial, key, close(TicketClosureReason::Unknown));
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect();
|
||||
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. })))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let ready = store
|
||||
.mark_ready(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready".into(),
|
||||
reason: None,
|
||||
intake_summary: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(ready.meta.readiness.as_deref(), Some("ready"));
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.unwrap();
|
||||
let queued = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
assert_eq!(queued.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(queued.meta.queued_by.as_deref(), Some("router"));
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&queued, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let other =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace")
|
||||
.unwrap();
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
assert!(matches!(
|
||||
other.update_case(&initial.meta.id, operation.clone()),
|
||||
Err(TicketError::NotFound(_))
|
||||
));
|
||||
let output = execute_ticket_backend_operation(
|
||||
&store,
|
||||
TicketBackendOperation::UpdateCase {
|
||||
ticket: initial.meta.id.clone(),
|
||||
request: operation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let historical = store.clone().with_event_attributes(BTreeMap::from([
|
||||
("operation_key".into(), "old-completion".into()),
|
||||
("merge_request_id".into(), "MR-existing".into()),
|
||||
]));
|
||||
let mut event = NewTicketEvent::new(
|
||||
TicketEventKind::Other("ticket_completion".into()),
|
||||
"Historic completed result",
|
||||
);
|
||||
event.references = vec![TicketReference {
|
||||
kind: "merge_request".into(),
|
||||
target: "MR-existing".into(),
|
||||
}];
|
||||
historical
|
||||
.add_event(initial.meta.id.clone().into(), event)
|
||||
.unwrap();
|
||||
let before = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
let after = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&before, "new-close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&after.events[..before.events.len()],
|
||||
before.events.as_slice()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&before,
|
||||
"old-completion",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() {
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"in_progress",
|
||||
] {
|
||||
assert!(serde_json::from_value::<TicketState>(serde_json::json!(state)).is_err());
|
||||
}
|
||||
let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}});
|
||||
let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(action).unwrap(), encoded);
|
||||
assert!(
|
||||
serde_json::from_value::<TicketCaseAction>(
|
||||
serde_json::json!({"action": "reopen", "reason": "completed"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn assignment_source() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime-a".into()),
|
||||
("source_worker_id".into(), "worker-a".into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_assignment_id".into(), "assignment-a".into()),
|
||||
])
|
||||
}
|
||||
fn assignment_fixture(temp: &TempDir, id: &str) -> Connection {
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT);
|
||||
CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap();
|
||||
conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let conn = assignment_fixture(&temp, &initial.meta.id);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
"source_actor_role",
|
||||
] {
|
||||
let mut wrong = assignment_source();
|
||||
wrong.insert(key.into(), "mismatch".into());
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(wrong)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
] {
|
||||
let mut missing = assignment_source();
|
||||
missing.remove(key);
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(missing)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let source = store.clone().with_event_attributes(assignment_source());
|
||||
let closed = source
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
// The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this.
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"active"
|
||||
);
|
||||
conn.execute("UPDATE test_worker_assignments SET status = 'released'", [])
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
source.update_case(&initial.meta.id, operation).unwrap(),
|
||||
reopened
|
||||
);
|
||||
assert!(
|
||||
source
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&reopened, "new-close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"released"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let digest = ticket_content_digest(&initial);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&closed), digest);
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(MarkdownText::new("New criteria")),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&edited), digest);
|
||||
let restored = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(initial.document.body.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&restored), digest);
|
||||
let targeted = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
targets: Some(TicketTargetsEdit::Set {
|
||||
targets: vec![TicketTarget {
|
||||
repository_key: "main".into(),
|
||||
ref_selector: None,
|
||||
access: TicketTargetAccess::ReadOnly,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&targeted), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_request_requires_an_explicit_nullable_closure_observation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let original = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
let mut json = serde_json::to_value(&original).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TicketCaseUpdate>(json.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
json.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("expected_closure_operation_key");
|
||||
assert!(serde_json::from_value::<TicketCaseUpdate>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let context = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"operation_key".into(),
|
||||
"wrong-key".into(),
|
||||
)]));
|
||||
assert!(
|
||||
context
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract.
|
||||
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_attributes (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_references (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
event_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
author TEXT,
|
||||
at TEXT,
|
||||
status TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
reason TEXT,
|
||||
state_field TEXT,
|
||||
heading TEXT,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_labels (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_orchestration_plans (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
related_ticket TEXT,
|
||||
note TEXT,
|
||||
accepted_summary TEXT,
|
||||
accepted_branch TEXT,
|
||||
accepted_worktree TEXT,
|
||||
accepted_role_plan TEXT,
|
||||
author TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, record_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_raw_frontmatter (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_relations (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, kind, target),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_risk_flags (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_tickets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
assignee TEXT,
|
||||
readiness TEXT,
|
||||
workflow_state TEXT NOT NULL,
|
||||
workflow_state_explicit INTEGER NOT NULL,
|
||||
queued_by TEXT,
|
||||
queued_at TEXT,
|
||||
resolution TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id)
|
||||
);
|
||||
CREATE TABLE typed_ticket_targets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
repository_key TEXT NOT NULL,
|
||||
ref_selector TEXT,
|
||||
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
UNIQUE (workspace_id, ticket_id, repository_key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE "workspace_resource_key_counters" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||
PRIMARY KEY (workspace_id, resource_kind)
|
||||
);
|
||||
CREATE TABLE "workspace_resource_keys" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
resource_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||
resource_key TEXT NOT NULL,
|
||||
allocated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||
UNIQUE (workspace_id, resource_kind, sequence),
|
||||
UNIQUE (workspace_id, resource_key)
|
||||
);
|
||||
CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE INDEX typed_ticket_relations_workspace_source_kind
|
||||
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
|
||||
CREATE INDEX typed_ticket_relations_workspace_target_kind
|
||||
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_created
|
||||
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_state_updated
|
||||
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_title
|
||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_updated
|
||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- Frozen standalone component8 tag extension; combine with schema7.sql.
|
||||
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
|
||||
CREATE TABLE workspace_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
last_operation_key TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
|
||||
PRIMARY KEY (workspace_id, tag_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_tags_active_name
|
||||
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
|
||||
CREATE TABLE typed_ticket_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, tag_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX typed_ticket_tags_workspace_tag
|
||||
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
|
||||
-- Immutable operation receipt and Workspace audit: exact input includes actor
|
||||
-- attributes; before/result preserve stable identities and display-name snapshots.
|
||||
-- No foreign keys to mutable rows: history survives deletion of current links.
|
||||
CREATE TABLE workspace_tag_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
input_json TEXT NOT NULL,
|
||||
source_context_json TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_key)
|
||||
);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- Frozen standalone component9 case extension; combine with schema7.sql.
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
@@ -0,0 +1,818 @@
|
||||
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
|
||||
//! compose into schema10, preserving original evidence and routing handoff.
|
||||
use rusqlite::{Connection, params};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn schema7(connection: &Connection) {
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema7.sql"))
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
|
||||
{
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("tickets.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
|
||||
.unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
|
||||
// Actual pre-content receipt spelling: preserve it as historical input,
|
||||
// never promote an old counter fingerprint into a current content CAS.
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
|
||||
connection.execute(
|
||||
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
|
||||
[state],
|
||||
).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
|
||||
params![state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Snapshot evidence before migration, including untyped `done` and existing MR references.
|
||||
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
let old_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let preserved_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(old_attributes, preserved_attributes);
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
let marker: i64 = connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(marker, 10);
|
||||
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
assert_eq!(after_events, historical_events);
|
||||
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
let ticket = store.show(state.into()).unwrap();
|
||||
let expected_state = if matches!(state, "done" | "closed") {
|
||||
TicketState::Closed
|
||||
} else {
|
||||
TicketState::Open
|
||||
};
|
||||
assert_eq!(ticket.meta.workflow_state, expected_state);
|
||||
let old_key_request = TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "legacy-receipt".into(),
|
||||
action: if expected_state == TicketState::Closed {
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
} else {
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
},
|
||||
explanation: "Cannot reinterpret a frozen operation receipt".into(),
|
||||
references: vec![],
|
||||
author: Some("human".into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.update_case(state, old_key_request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(state.into()).unwrap(), ticket);
|
||||
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
|
||||
assert_eq!(legacy.workflow_state, state);
|
||||
assert!(legacy.workflow_state_explicit);
|
||||
assert_eq!(legacy.status, "open");
|
||||
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
|
||||
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
|
||||
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
|
||||
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
|
||||
assert_eq!(ticket.meta.targets[0].repository_key, "main");
|
||||
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
|
||||
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
|
||||
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
|
||||
assert_eq!(
|
||||
ticket.events[0].attributes["request_fingerprint"],
|
||||
"legacy-fingerprint"
|
||||
);
|
||||
if expected_state == TicketState::Closed {
|
||||
let judgment = ticket.current_closure.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
judgment.reason,
|
||||
TicketClosureReason::Unknown,
|
||||
"old done must never mean successful completion"
|
||||
);
|
||||
assert_eq!(judgment.explanation, "Original decision text");
|
||||
let reopened = store
|
||||
.update_case(
|
||||
state,
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "reopen-migrated".into(),
|
||||
action: TicketCaseAction::Reopen,
|
||||
explanation: "Reconsider the case".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("human".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(
|
||||
reopened.legacy_workflow_snapshot,
|
||||
ticket.legacy_workflow_snapshot
|
||||
);
|
||||
} else {
|
||||
assert!(ticket.current_closure.is_none());
|
||||
}
|
||||
}
|
||||
let released: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(released, 6);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"future-state"
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
|
||||
.unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
// Read actual SQLite values, not merely counts: receipt JSON, original context,
|
||||
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
|
||||
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let mut stmt = connection
|
||||
.prepare(&format!("SELECT * FROM {table} ORDER BY rowid"))
|
||||
.unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) {
|
||||
connection
|
||||
.execute(
|
||||
"ATTACH DATABASE ?1 AS fixture_source",
|
||||
[source.to_str().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
for table in tables {
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"INSERT INTO {table} SELECT * FROM fixture_source.{table}"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
connection
|
||||
.execute_batch("DETACH DATABASE fixture_source")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "Retain this judgment".into(),
|
||||
references: vec![TicketReference {
|
||||
kind: "result".into(),
|
||||
target: "retained-result".into(),
|
||||
}],
|
||||
author: Some("authenticated-user".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source_path = temp.path().join("tag-operations.db");
|
||||
let source = SqliteTicketBackend::open(&source_path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
("source_actor_role".into(), "human".into()),
|
||||
]));
|
||||
let id = source
|
||||
.create(NewTicket::new("historical tagged ticket"))
|
||||
.unwrap()
|
||||
.id;
|
||||
// Generate real durable T-740 receipts/audit/event snapshots through its
|
||||
// unchanged classification boundary, then seed only those rows into frozen8.
|
||||
let create = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "open".into(),
|
||||
},
|
||||
};
|
||||
let original = source.mutate_workspace_tag(create.clone()).unwrap();
|
||||
let attach = TicketTagSetRequest {
|
||||
operation_key: "attach".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec![original.id.clone()],
|
||||
};
|
||||
let attach_result = source
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.unwrap();
|
||||
let renamed = mutate(
|
||||
&source,
|
||||
"rename",
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: original.id.clone(),
|
||||
expected_last_operation_key: "create".into(),
|
||||
name: "closed".into(),
|
||||
},
|
||||
);
|
||||
let deleted = mutate(
|
||||
&source,
|
||||
"delete",
|
||||
WorkspaceTagAction::Delete {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
},
|
||||
);
|
||||
let recreated = mutate(
|
||||
&source,
|
||||
"recreate",
|
||||
WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
);
|
||||
assert_ne!(recreated.id, deleted.id);
|
||||
source
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
TicketTagSetRequest {
|
||||
operation_key: "attach-recreated".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec![recreated.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = temp.path().join("retained8.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema8_tags.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version=8", [])
|
||||
.unwrap();
|
||||
let preserved = [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_event_references",
|
||||
];
|
||||
copy_tables(&connection, &source_path, &["typed_tickets"]);
|
||||
copy_tables(&connection, &source_path, &preserved);
|
||||
// Six-state retained data, not a current10 DB with its number changed.
|
||||
connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap();
|
||||
let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect();
|
||||
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert!(verify_sqlite_ticket_schema(&connection).is_err());
|
||||
assert!(verify_pre_classification_ticket_schema(&connection).is_err());
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
rows(&connection, "ticket_schema_migrations")[0][0],
|
||||
rusqlite::types::Value::Integer(10)
|
||||
);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(&rows(&connection, table), expected, "changed {table}");
|
||||
}
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
("source_actor_role".into(), "human".into()),
|
||||
]));
|
||||
let ticket = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(ticket.tags, vec![recreated]);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&ticket),
|
||||
ticket_content_digest(&source.show(id.clone().into()).unwrap())
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.update_case(
|
||||
&id,
|
||||
case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen)
|
||||
),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
ticket.current_closure.as_ref().unwrap().reason,
|
||||
TicketClosureReason::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
ticket.current_closure.as_ref().unwrap().explanation,
|
||||
"Historical explanation"
|
||||
);
|
||||
assert_eq!(
|
||||
ticket
|
||||
.legacy_workflow_snapshot
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.workflow_state,
|
||||
"done"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(create.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.unwrap(),
|
||||
attach_result
|
||||
);
|
||||
let other = backend
|
||||
.clone()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "other-account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
]));
|
||||
assert!(matches!(
|
||||
other.mutate_workspace_tag(create),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
other.set_ticket_tags(id.clone().into(), attach),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
TicketTagSetRequest {
|
||||
operation_key: "attach-deleted".into(),
|
||||
expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec![deleted.id.clone()],
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)",
|
||||
params![id, deleted.id]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let judgments = rows(&connection, "typed_ticket_case_history");
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(
|
||||
&rows(&connection, table),
|
||||
expected,
|
||||
"replay changed {table}"
|
||||
);
|
||||
}
|
||||
let reopened = backend
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(&ticket, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.tags, ticket.tags);
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&reopened),
|
||||
ticket_content_digest(&ticket)
|
||||
);
|
||||
assert!(reopened.current_closure.is_none());
|
||||
let fk_failures: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fk_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source_path = temp.path().join("case-operations.db");
|
||||
let source = SqliteTicketBackend::open(&source_path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
]));
|
||||
let id = source.create(NewTicket::new("existing case")).unwrap().id;
|
||||
let before = source.show(id.clone().into()).unwrap();
|
||||
let close = case_request(
|
||||
&before,
|
||||
"close",
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Duplicate,
|
||||
},
|
||||
);
|
||||
let closed = source.update_case(&id, close.clone()).unwrap();
|
||||
let corrected = source
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::NotPlanned,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = source
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
let path = temp.path().join("retained9.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version=9", [])
|
||||
.unwrap();
|
||||
let tables = [
|
||||
"typed_tickets",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_event_references",
|
||||
"workspace_resource_keys",
|
||||
"workspace_resource_key_counters",
|
||||
];
|
||||
copy_tables(&connection, &source_path, &tables);
|
||||
// A migrated closed case already judged Unknown by standalone9 must remain
|
||||
// exactly that judgment, with its original migration actor/key and handoff.
|
||||
connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap();
|
||||
let snapshot = TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: "done".into(),
|
||||
workflow_state_explicit: true,
|
||||
status: "done".into(),
|
||||
queued_by: Some("original-router".into()),
|
||||
queued_at: Some("original-time".into()),
|
||||
resolution: Some("Original legacy close".into()),
|
||||
};
|
||||
let judgment = TicketCaseHistoryEntry {
|
||||
operation_key: "migration-v9:old9".into(),
|
||||
previous_closure_operation_key: None,
|
||||
action: TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Original legacy close".into(),
|
||||
references: vec![],
|
||||
author: "ticket-schema-migration-v9".into(),
|
||||
at: "original-migration-time".into(),
|
||||
};
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)",
|
||||
[serde_json::to_string(&snapshot).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap();
|
||||
let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
for (table, expected) in tables.iter().zip(&original) {
|
||||
assert_eq!(
|
||||
&rows(&connection, table),
|
||||
expected,
|
||||
"changed independent9 {table}"
|
||||
);
|
||||
}
|
||||
assert!(rows(&connection, "workspace_tags").is_empty());
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
]));
|
||||
let old9 = backend.show("old9".into()).unwrap();
|
||||
assert_eq!(old9.case_history, vec![judgment]);
|
||||
assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot));
|
||||
assert_eq!(
|
||||
old9.current_closure.unwrap().reason,
|
||||
TicketClosureReason::Unknown
|
||||
);
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap(), reopened);
|
||||
assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened);
|
||||
let other = backend
|
||||
.clone()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "other-worker".into()),
|
||||
]));
|
||||
assert!(matches!(
|
||||
other.update_case(&id, close),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
|
||||
connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert!(!connection.is_autocommit());
|
||||
connection.execute_batch("ROLLBACK").unwrap();
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert!(rows(&connection, "host_work").is_empty());
|
||||
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() {
|
||||
for drift in [
|
||||
"UPDATE ticket_schema_migrations SET version=9",
|
||||
"UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;",
|
||||
] {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
if drift.contains("DROP TRIGGER") {
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
|
||||
.unwrap();
|
||||
}
|
||||
connection.execute_batch(drift).unwrap();
|
||||
let marker = rows(&connection, "ticket_schema_migrations");
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(rows(&connection, "ticket_schema_migrations"), marker);
|
||||
let tags: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection.execute_batch("CREATE TABLE host_work (value TEXT);
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1);
|
||||
CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
BEGIN SELECT RAISE(ABORT,'host failure'); END;
|
||||
BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
|
||||
assert!(!connection.is_autocommit());
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "host_work").len(), 1);
|
||||
let tags: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0, "DDL before failed state conversion must roll back");
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER host_abort_case_update")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "host_work").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap();
|
||||
// Ticket accepts stricter host-owned Workspace references and must preserve
|
||||
// them, not rebuild parents against its weaker standalone authority schema.
|
||||
let schema = include_str!("fixtures/schema7.sql");
|
||||
let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap();
|
||||
let (ticket, after) = ticket
|
||||
.split_once("CREATE TABLE typed_ticket_targets (")
|
||||
.unwrap();
|
||||
let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)",
|
||||
"PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}"
|
||||
))
|
||||
.unwrap();
|
||||
let tags = include_str!("fixtures/schema8_tags.sql")
|
||||
.replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE")
|
||||
.replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
|
||||
connection.execute_batch(&tags).unwrap();
|
||||
connection.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES(8,'ticket schema baseline','retained');
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1);
|
||||
INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0);
|
||||
INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap();
|
||||
let signature = |table| {
|
||||
let mut stmt = connection
|
||||
.prepare(&format!(
|
||||
"SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"
|
||||
))
|
||||
.unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| {
|
||||
(0..count)
|
||||
.map(|i| row.get::<_, rusqlite::types::Value>(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let tables = [
|
||||
"typed_tickets",
|
||||
"workspace_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_tags",
|
||||
];
|
||||
let before: Vec<_> = tables.iter().map(|t| signature(t)).collect();
|
||||
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
for (table, expected) in tables.iter().zip(before) {
|
||||
assert_eq!(signature(table), expected, "lost host FK on {table}");
|
||||
}
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let failures: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failures, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user