feat: unify Ticket posts and simplify detail layout

Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
2026-10-14 02:42:58 +09:00
parent 26ab6130e0
commit 8611078058
45 changed files with 5862 additions and 668 deletions
@@ -78,7 +78,8 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_
);
// Model the authority change after authenticated source capture, before
// BEGIN IMMEDIATE on a second connection. Receipt presence cannot bypass it.
let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let second =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
second.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
match change {
@@ -233,7 +234,8 @@ async fn tracking_store_same_principal_reissue_and_exact_retries_keep_original_r
.is_err()
);
drop(store);
let reopened = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let reopened =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
assert_eq!(
reopened
.get_ticket_assignment_tracking("w", "tracking", "a")
@@ -300,10 +302,11 @@ fn tracking_schema91_fixture(path: &Path) -> Connection {
// Genuine retained component-10 DDL, before assignment tracking existed.
// The host structure is unchanged from Workspace 91: no tracking columns,
// tracking tables/triggers, or component-11 marker are created here.
conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql"))
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap();
create_workspace_schema_structure(&conn).unwrap();
create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false)
.unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(91,'workspace schema baseline')",
[],
@@ -439,12 +442,12 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
.iter()
.map(|m| m.version)
.collect::<Vec<_>>(),
vec![92, 93]
vec![92, 93, 94]
);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
conn.query_row(
"SELECT applied_at FROM ticket_schema_migrations WHERE version=10",
@@ -461,7 +464,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(
93,
"Durable assignment deliveries and source-bound startup fences".into()
)
),
(94, "Canonical Ticket posts".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -599,8 +603,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
.is_empty()
);
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 11);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 12);
assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed");
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage");
@@ -1068,7 +1072,8 @@ async fn orchestrator_assignment_all_runtime_retries_fence_singleton_and_binding
&source,
)
.unwrap();
let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let second =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
second.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;match change {
"replace" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;},
"revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;},
@@ -1364,7 +1369,7 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound
})
.await
.unwrap();
store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','','open',1)",[])?;Ok(())}).unwrap();
store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','open',1)",[])?;conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('other','tracking',-1,'tracking:-1','comment','')",[])?;Ok(())}).unwrap();
let other_workspace = TicketRoleAssignmentRecord {
workspace_id: "other".into(),
..orchestrator_mutation_record("history-a")
+35 -15
View File
@@ -489,10 +489,10 @@ impl SqliteWorkspaceAuthority {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
predicates.push(format!(
"(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS (
"(lower(t.title) LIKE {p} OR lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {p} OR EXISTS (
SELECT 1 FROM typed_ticket_events e
WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
AND lower(e.body) LIKE {p}) OR EXISTS (
AND e.event_index >= 0 AND e.archived=0 AND lower(e.body) LIKE {p}) OR EXISTS (
SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id
AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))",
p = pattern
@@ -542,7 +542,7 @@ impl SqliteWorkspaceAuthority {
.iter()
.map(|kind| bind(SqlValue::Text(kind.clone())))
.collect::<Vec<_>>();
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind IN ({}))", event_kinds.join(",")));
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND event.kind IN ({}))", event_kinds.join(",")));
}
// SQL only narrows immutable Ticket metadata. Review/evidence filters
// must use the same provider + complete linked-result projection as ShowTicket;
@@ -560,7 +560,7 @@ impl SqliteWorkspaceAuthority {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
format!(
"CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower(t.body) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END"
"CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END"
)
} else {
"3".to_string()
@@ -762,8 +762,12 @@ impl SqliteWorkspaceAuthority {
let id = ticket.meta.id.as_str();
let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
let initial = ticket.events.iter().find(|event| event.is_initial)
.ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?;
let initial_post = ticket_event_detail(0, initial);
let body = initial_post.body.clone().unwrap_or_default();
let body_truncated = initial_post.body_truncated;
let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::<Vec<_>>();
let event_limit = request
.event_limit
.unwrap_or(TICKET_EVENT_LIMIT)
@@ -773,10 +777,10 @@ impl SqliteWorkspaceAuthority {
.as_deref()
.map(|cursor| parse_offset_cursor(cursor, "event_cursor"))
.transpose()?
.unwrap_or(ticket.events.len())
.min(ticket.events.len());
.unwrap_or(subsequent_events.len())
.min(subsequent_events.len());
let event_start = event_end.saturating_sub(event_limit);
let events = ticket.events[event_start..event_end]
let events = subsequent_events[event_start..event_end]
.iter()
.enumerate()
.map(|(index, event)| ticket_event_detail(event_start + index, event))
@@ -795,8 +799,7 @@ impl SqliteWorkspaceAuthority {
})
})
.collect::<Result<Vec<_>>>()?;
let implementation_reports = ticket
.events
let implementation_reports = subsequent_events
.iter()
.enumerate()
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
@@ -996,6 +999,8 @@ impl SqliteWorkspaceAuthority {
.store
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?;
Ok(TicketDetail {
initial_post,
created_by: ticket.meta.created_by,
assignment_tracking,
id: ticket.meta.id,
resource_key,
@@ -1017,7 +1022,7 @@ impl SqliteWorkspaceAuthority {
tags: ticket.tags,
body,
body_truncated,
event_count: ticket.events.len(),
event_count: subsequent_events.len(),
events,
event_page: QueryPage {
limit: event_limit,
@@ -1770,6 +1775,9 @@ impl std::fmt::Display for ObjectiveQuerySort {
}
fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
if !event.event_ref.is_empty() {
return event.event_ref.clone();
}
event
.attributes
.get("event_id")
@@ -1777,8 +1785,20 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
.unwrap_or_else(|| format!("sequence:{sequence}"))
}
fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail {
pub(crate) fn ticket_post_body_digest(body: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(body.as_bytes()))
}
pub(crate) fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail {
let (body, body_truncated) = truncate_body(event.body.as_str(), if event.is_initial { DETAIL_BODY_LIMIT } else { TICKET_EVENT_BODY_LIMIT });
TicketEventDetail {
is_initial: event.is_initial,
edited_at: event.edited_at.clone(),
edited_by: event.edited_by.clone(),
editable: event.kind == ticket::TicketEventKind::Comment,
body_digest: ticket_post_body_digest(event.body.as_str()),
body_truncated,
sequence,
event_ref: ticket_event_ref(sequence, event),
kind: event.kind.as_str().to_owned(),
@@ -1790,8 +1810,7 @@ fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetai
reason: event.reason.clone(),
state_field: event.state_field.clone(),
heading: event.heading.clone(),
body: (!event.body.as_str().is_empty())
.then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0),
body: Some(body),
attributes: event.attributes.clone(),
references: event
.references
@@ -2468,6 +2487,7 @@ fn ticket_query_item(
if let Some((sequence, event)) = authoritative_events
.iter()
.enumerate()
.filter(|(_, event)| !event.is_initial)
.find(|(_, event)| event.body.as_str().to_lowercase().contains(&needle))
{
matched_fields.push("event".to_string());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
-- 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);
-- 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)
);
@@ -437,6 +437,10 @@ CREATE TABLE typed_ticket_events (
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
event_ref TEXT NOT NULL DEFAULT '',
edited_at TEXT,
edited_by TEXT,
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)),
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
@@ -493,7 +497,7 @@ CREATE TABLE "typed_tickets" (
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_by TEXT,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
@@ -1113,6 +1117,7 @@ CREATE INDEX repositories_workspace_provider_idx
ON repositories(workspace_id, provider);
CREATE UNIQUE INDEX typed_ticket_events_ref ON typed_ticket_events(workspace_id, ticket_id, event_ref);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
+4 -3
View File
@@ -1135,10 +1135,11 @@ mod tests {
[worker_id().to_string()],
)?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)",
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', 'open', 1)",
[],
)?;
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w','ticket',-1,'ticket:-1','comment','')", [])?;
Ok(())
})
.unwrap();
@@ -1398,7 +1399,7 @@ mod tests {
})
.unwrap();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','open',1)", [])?;
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','open',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
+164 -93
View File
@@ -10285,14 +10285,13 @@ impl server_api::ServerApi for ServerApiContractService {
workspace_id: String,
request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
let Json(response) = scoped_create_ticket_record(
State(self.workspace_api()?.clone()),
AxumPath(ScopedWorkspacePath { workspace_id }),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::Create { input: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecordRef(response))
}
@@ -10432,14 +10431,13 @@ impl server_api::ServerApi for ServerApiContractService {
id: String,
request: server_api::EditTicketRecordItemRequest,
) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> {
let Json(response) = scoped_edit_ticket_record_item(
State(self.workspace_api()?.clone()),
AxumPath((workspace_id, id)),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecord(response))
}
@@ -10469,14 +10467,11 @@ impl server_api::ServerApi for ServerApiContractService {
id: String,
request: server_api::TicketThreadEventRequest,
) -> std::result::Result<(), server_api::RepositoryApiError> {
scoped_add_ticket_thread_event(
State(self.workspace_api()?.clone()),
AxumPath((workspace_id, id)),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?;
Ok(())
}
@@ -10936,12 +10931,14 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_edit(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::BrowserEditTicketRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_edit_ticket_item(
State(self.workspace_api()?.clone()),
&context,
AxumPath(ScopedRecordPath { workspace_id, id }),
Json(request),
)
@@ -10950,6 +10947,22 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(response)
}
async fn ticket_post_get(
&self, workspace_id: String, id: String, event_ref: String,
) -> std::result::Result<server_api::TicketEventDetail, server_api::RepositoryApiError> {
scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref)
.await.map_err(ApiError::into_repository_api_error)
}
async fn ticket_post_edit(
&self, context: server_api::ServerRequestContext, workspace_id: String, id: String,
event_ref: String, request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context,
workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?;
Ok(detail)
}
async fn ticket_show(
&self,
workspace_id: String,
@@ -11297,12 +11310,14 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_event_append(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::BrowserAppendTicketEventRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_append_ticket_event(
State(self.workspace_api()?.clone()),
&context,
AxumPath(ScopedRecordPath { workspace_id, id }),
Json(request),
)
@@ -15414,84 +15429,122 @@ fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<
async fn scoped_edit_ticket_item(
State(api): State<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserEditTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let mut targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets
.into_iter()
.map(|target| ticket::TicketTarget {
repository_key: target.repository_key,
ref_selector: target.ref_selector,
access: target.access,
})
.collect(),
},
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
});
if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() {
validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?;
}
browser_ticket_backend(&api)?
.edit_item(
TicketIdOrSlug::Id(path.id.clone()),
TicketItemEdit {
title: request.title,
body: request.body.map(MarkdownText::new),
body_replacement: match (request.old_string, request.new_string) {
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement {
old_string,
new_string,
replace_all: request.replace_all,
}),
(None, None) => None,
_ => {
return Err(settings_bad_request(
"invalid_ticket_edit_replacement",
"old_string and new_string must be provided together",
));
}
},
targets,
author: request.author,
},
)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
})
.await
let targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets.into_iter().map(|target| ticket::TicketTarget {
repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access,
}).collect(),
},
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
});
let body_replacement = match (request.old_string, request.new_string) {
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { old_string, new_string, replace_all: request.replace_all }),
(None, None) => None,
_ => return Err(settings_bad_request("invalid_ticket_edit_replacement", "old_string and new_string must be provided together")),
};
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit {
title: request.title, body: request.body.map(MarkdownText::new), body_replacement,
targets, author: request.author,
} }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
}
async fn scoped_append_ticket_event(
State(api): State<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserAppendTicketEventRequest>,
) -> ApiResult<Json<TicketDetail>> {
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let role = match request.role {
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => {
TicketEventKind::ImplementationReport
}
};
let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author;
browser_ticket_backend(&api)?
.add_event(TicketIdOrSlug::Id(path.id.clone()), event)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
})
.await
let role = match request.role {
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport,
};
let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author;
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
}
const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024;
fn authenticated_ticket_post_author(
context: &server_api::ServerRequestContext,
source: Option<&WorkerMutationSource>,
) -> Result<String> {
if let Some(source) = source {
return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id));
}
let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied(
"Ticket posts require an authenticated actor".into()))?;
let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id))
.map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?;
Ok(format!("workspace-user:{identity}"))
}
async fn scoped_get_ticket_post(
api: WorkspaceApi, workspace_id: String, id: String, event_ref: String,
) -> ApiResult<server_api::TicketEventDetail> {
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let ticket = browser_ticket_backend(&api)?.show(TicketIdOrSlug::Id(id)).map_err(Error::from)?;
let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?;
if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the full-post read limit"));
}
let mut detail = crate::authority::ticket_event_detail(sequence, post);
detail.body = Some(post.body.as_str().to_string());
detail.body_truncated = false;
Ok(detail)
}).await
}
async fn scoped_edit_ticket_post(
api: WorkspaceApi, context: server_api::ServerRequestContext,
workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest,
) -> ApiResult<Json<TicketDetail>> {
let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
let source = authenticated_ticket_worker_source(&headers, &context)?;
let author = authenticated_ticket_post_author(&context, source.as_ref())?;
if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit limit"));
}
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let backend = browser_ticket_backend(&api)?;
let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?;
let post = observed.events.iter().find(|post| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?;
if crate::authority::ticket_post_body_digest(post.body.as_str()) != request.expected_body_digest {
return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into());
}
let is_initial = post.is_initial;
// The digest is a transport observation; storage compares its exact body in the write transaction.
let (previous, current) = backend.edit_post_with_snapshots(TicketIdOrSlug::Id(id.clone()), ticket::TicketPostEdit {
event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author,
}).map_err(Error::from)?;
notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(), source.clone());
if is_initial && let Some(source) = source {
schedule_ticket_item_check(&api, &current, TicketItemCheckEdit::Edit {
title_changed: false, body_changed: true, previous_title: None,
previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None,
}, source);
}
browser_ticket_detail(&api, &id)
}).await
}
async fn scoped_mark_ticket_ready_from_browser(
@@ -15802,7 +15855,22 @@ fn execute_ticket_rest_operation_on_host(
)
.into());
}
let source = verified_source.or(hinted_source);
let source = if let Some(context) = context {
authenticated_ticket_worker_source(&headers, context)?
} else {
verified_source.or(hinted_source)
};
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. })
&& let Some(context) = context
{
let author = authenticated_ticket_post_author(context, source.as_ref())?;
match &mut operation {
TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
_ => unreachable!(),
}
}
validate_ticket_repository_operation(api, &operation)?;
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
if let Some(source) = source.as_ref()
@@ -47377,6 +47445,7 @@ mod tests {
.unwrap();
let Json(_) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_ref.id.clone(),
@@ -53470,6 +53539,7 @@ mod tests {
let Json(edited) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(path()),
Json(server_api::BrowserEditTicketRequest {
title: Some("Browser Ticket API edited".to_string()),
@@ -53507,6 +53577,7 @@ mod tests {
let Json(commented) = scoped_append_ticket_event(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(path()),
Json(server_api::BrowserAppendTicketEventRequest {
role: server_api::BrowserTicketThreadRole::Comment,
+77 -22
View File
@@ -73,7 +73,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 93;
const LATEST_SCHEMA_VERSION: i64 = 94;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -346,6 +346,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable assignment deliveries and source-bound startup fences",
apply: migrate_assignment_delivery_v92_to_v93,
},
Migration {
version: 94,
name: "Canonical Ticket posts",
apply: migrate_ticket_posts_v93_to_v94,
},
];
#[derive(Clone, Copy)]
@@ -8602,9 +8607,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
tx.execute(
"INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at,
from_state, to_state, reason, state_field, heading, body
from_state, to_state, reason, state_field, heading, body, event_ref
) VALUES (?1, ?2, ?3, 'assignment', ?4, ?5, NULL, NULL,
'manual Worker assignment accepted', NULL, 'Worker assigned', '')",
'manual Worker assignment accepted', NULL, 'Worker assigned', '', ?2 || ':' || ?3)",
params![record.workspace_id, record.ticket_id, event_index,
record.assigned_by, record.assigned_at],
)?;
@@ -13990,6 +13995,33 @@ fn migrate_assignment_delivery_v92_to_v93(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 93 {
return Err(Error::Store(
"Ticket posts migration requires schema 93".into(),
));
}
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
[],
)?;
verify_workspace_resource_constraints(&tx)?;
let violations: i64 =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if violations != 0 {
return Err(Error::Store(format!(
"Ticket posts migration left {violations} foreign-key violations"
)));
}
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
@@ -14000,10 +14032,17 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
})
}
// Workspace 92 changes only the Ticket-owned assignment component. Retained
// Workspace-91 fixtures compose this host structure with the genuine Ticket-10
// schema, not a current database with lowered migration markers.
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)
}
fn create_workspace_schema_structure_from_sql(
conn: &Connection,
schema_sql: &str,
ticket_posts: bool,
) -> Result<()> {
// The composed Workspace parent FK rebuild must not discard domain-owned
// case guards. Retain their SQL from the shared fresh Ticket schema.
let ticket_parent_triggers = conn
@@ -14021,12 +14060,11 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?;
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(&include_str!("latest_schema.sql").replace(
conn.execute_batch(&schema_sql.replace(
"-- Ticket-owned assignment schema is composed by store.rs.",
ticket::TICKET_ASSIGNMENT_SCHEMA_SQL,
))?;
conn.execute_batch(
r#"
let composed_sql = r#"
ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66;
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
@@ -14088,8 +14126,13 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
CHECK (capabilities IN (
'all', 'read_only', 'read_write', 'command_only', 'read_command'
));
"#,
)?;
"#;
let composed_sql = if ticket_posts {
composed_sql.replace("body TEXT NOT NULL,", "created_by TEXT,")
} else {
composed_sql.to_string()
};
conn.execute_batch(&composed_sql)?;
install_ticket_case_assignment_work_schema(conn)?;
for sql in ticket_parent_triggers {
conn.execute_batch(&sql)?;
@@ -16849,6 +16892,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs");
include!("ticket_post_upgrade_tests.rs");
include!("migration_entrypoint_tests.rs");
include!("legacy_evidence_column_tests.rs");
@@ -16869,7 +16913,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
configure_sqlite(&conn).unwrap();
// Retained fixtures start from genuine pre-tracking Ticket DDL; current
// component 11 would introduce assignment objects before frozen host 85.
conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql"))
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap();
merge_request::migrate(&conn).unwrap();
@@ -17181,6 +17225,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
},
WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()},
WorkspaceSchemaMigrationStep {version:93, name:"Durable assignment deliveries and source-bound startup fences".into()},
WorkspaceSchemaMigrationStep {version:94, name:"Canonical Ticket posts".into()},
]
);
@@ -17284,6 +17329,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(91, "Ticket open/closed cases and retained routing evidence".to_string()),
(92, "Ticket-owned assignment tracking".to_string()),
(93, "Durable assignment deliveries and source-bound startup fences".to_string()),
(94, "Canonical Ticket posts".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -19696,6 +19742,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
// Compare preserved original assignment evidence, not newly added tracking columns.
if table == "typed_ticket_events" {
return fixture_query_rows(
conn,
"SELECT workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY rowid",
);
}
let columns = if table == "ticket_worker_assignments" {
"workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at"
} else {
@@ -19881,9 +19933,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
.map(|step| step.version)
.collect::<Vec<_>>(),
if version == 89 {
vec![90, 91, 92, 93]
vec![90, 91, 92, 93, 94]
} else {
vec![91, 92, 93]
vec![91, 92, 93, 94]
}
);
drop(conn);
@@ -19899,7 +19951,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
vec![(90, "Runtime binding connection configuration and historical verification archive".into()),
(91, "Ticket open/closed cases and retained routing evidence".into()),
(92, "Ticket-owned assignment tracking".into()),
(93, "Durable assignment deliveries and source-bound startup fences".into())]);
(93, "Durable assignment deliveries and source-bound startup fences".into()),
(94, "Canonical Ticket posts".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -20295,11 +20348,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
VALUES('guard-owner','user','guard-owner','Owner','1','1');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
VALUES('guard-workspace','guard-owner','Guard','active','1','1');
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','','open',1);")?;
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','open',1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('guard-workspace','guard-ticket',-1,'guard-ticket:-1','comment','');")?;
assert!(conn.execute("UPDATE typed_tickets SET workflow_state='done' WHERE ticket_id='guard-ticket'", []).is_err());
assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','','planning',1)", []).is_err());
assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','planning',1)", []).is_err());
assert!(conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at)
VALUES('guard-workspace','bad-runtime','Bad','https://invalid.invalid','key','fingerprint','binding','invalid-mode','1','1')", []).is_err());
Ok(())
@@ -20519,8 +20573,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) {
store.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at)
VALUES('w',?1,?1,'Ticket','open','task','normal','',?2,1,'1')", params![id,state])?;
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit,updated_at)
VALUES('w',?1,?1,'Ticket','open','task','normal',?2,1,'1')", params![id,state])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w',?1,-1,?1 || ':-1','comment','')", [id])?;
Ok(())
}).unwrap();
}
@@ -20932,7 +20987,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
reopened.with_conn(|conn| {
conn.execute_batch("UPDATE typed_tickets SET title='Changed requirements' WHERE workspace_id='w' AND ticket_id='first';
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket requirements changed');")?; Ok(())
UPDATE typed_ticket_events SET body='Ticket requirements changed',edited_at='2',edited_by='fixture-editor' WHERE workspace_id='w' AND ticket_id='first' AND event_index=-1;")?; Ok(())
}).unwrap();
assert!(
reopened
@@ -0,0 +1,369 @@
// Host/domain integration through real SQLite and public migration entrypoints.
// Retained fixtures use pre-posts DDL; no current schema is relabelled as old.
fn ticket_posts_schema93_fixture(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time');
PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap();
create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false)
.unwrap();
conn.execute_batch(include_str!(
"../../ticket/src/assignment_tracking_schema.sql"
))
.unwrap();
conn.execute_batch("INSERT INTO ticket_schema_migrations VALUES(11,'Ticket-owned assignment tracking','original-component11-time');
INSERT INTO __yoi_schema_migrations(version,name) VALUES(93,'workspace schema baseline');
PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;").unwrap();
assignment_delivery::install(&conn).unwrap();
seed_migration_workspace(&conn);
conn
}
fn seed_ticket_posts_legacy_content(conn: &Connection, workspace: &str) {
for (id, body) in [
("authored", "Current requirements after edits"),
("unknown", "Unknown creator body"),
] {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,updated_at,workflow_state,workflow_state_explicit)
VALUES(?1,?2,?2,'Retained Ticket','open','task','P2',?3,'2026-01-01T00:00:00Z','2026-02-01T00:00:00Z','open',1)", params![workspace,id,body]).unwrap();
}
for (index, kind, author, body) in [
(0, "create", Some("creator"), "Original creation audit"),
(3, "comment", Some("commenter"), "A preserved comment"),
(7, "item_edit", Some("editor"), "Original edit audit"),
(9, "comment", None, "Anonymous historical comment"),
] {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,'authored',?2,?3,?4,'2026-01-02T00:00:00Z',?5)",params![workspace,index,kind,author,body]).unwrap();
conn.execute("INSERT INTO typed_ticket_event_attributes VALUES(?1,'authored',?2,'source_kind','original-source')",params![workspace,index]).unwrap();
conn.execute("INSERT INTO typed_ticket_event_references VALUES(?1,'authored',?2,0,'ticket','T-original')",params![workspace,index]).unwrap();
}
}
#[test]
fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_audit() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
let old_events = stored_fixture_rows(&conn, "typed_ticket_events");
let old_attributes = stored_fixture_rows(&conn, "typed_ticket_event_attributes");
let old_references = stored_fixture_rows(&conn, "typed_ticket_event_references");
let old_markers = stored_fixture_rows(&conn, "ticket_schema_migrations");
drop(conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 93);
assert_eq!(
plan.migrations
.iter()
.map(|step| step.version)
.collect::<Vec<_>>(),
[94]
);
let conn = Connection::open(&path).unwrap();
assert!(
column_exists(&conn, "typed_tickets", "body").unwrap(),
"planning must not mutate its source"
);
assert_eq!(
stored_fixture_rows(&conn, "typed_ticket_events"),
old_events
);
drop(conn);
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
assert!(!column_exists(conn,"typed_tickets","body")?);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_events"),old_events);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_attributes"),old_attributes);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+1);
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
assert_eq!(initial,("Current requirements after edits".into(),Some("creator".into()),Some("creator".into()),"comment".into(),"authored:-1".into()));
let archive: Vec<(i64,String,i64)> = conn.prepare("SELECT event_index,event_ref,archived FROM typed_ticket_events WHERE ticket_id='authored' ORDER BY event_index")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::<rusqlite::Result<_>>()?;
assert_eq!(archive,vec![(-1,"authored:-1".into(),0),(0,"authored:0".into(),1),(3,"authored:3".into(),0),(7,"authored:7".into(),1),(9,"authored:9".into(),0)]);
let unknown: (Option<String>,Option<String>,String) = conn.query_row("SELECT t.created_by,e.author,e.body FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='unknown' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
assert_eq!(unknown,(None,None,"Unknown creator body".into()));
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
let loaded = backend.show("authored".into()).unwrap();
assert_eq!(loaded.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
loaded.document.body.as_str(),
"Current requirements after edits"
);
assert_eq!(
loaded
.events
.iter()
.map(|event| event.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
assert!(loaded.events[0].is_initial);
assert_eq!(loaded.events[1].author.as_deref(), Some("commenter"));
assert_eq!(loaded.events[2].author, None);
assert!(
loaded
.events
.iter()
.all(|event| event.edited_at.is_none() && event.edited_by.is_none())
);
drop(store);
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
reopened
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM typed_ticket_events WHERE event_index=-1",
[],
|r| r.get::<_, i64>(0)
)?,
2
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_schema_migrations WHERE version=12",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
}
#[test]
fn ticket_posts_schema94_keeps_body_edit_metadata_and_published_refs_across_reopen() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
conn.execute_batch("INSERT INTO typed_ticket_event_attributes VALUES
('space','authored',7,'changes','title,body'),
('space','authored',3,'event_id','raw-identity'),
('space','authored',9,'event_id','raw-identity');
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body)
VALUES('space','authored',15,'item_edit','title-editor','2026-03-01T00:00:00Z','Later title edit');
INSERT INTO typed_ticket_event_attributes VALUES('space','authored',15,'changes','title');").unwrap();
drop(conn);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
let migrated = backend.show("authored".into()).unwrap();
assert_eq!(migrated.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
migrated.meta.created_at.as_deref(),
Some("2026-01-01T00:00:00Z")
);
let initial = &migrated.events[0];
assert_eq!(initial.author.as_deref(), Some("creator"));
assert_eq!(initial.at.as_deref(), Some("2026-01-02T00:00:00Z"));
assert_eq!(initial.edited_by.as_deref(), Some("editor"));
assert_eq!(initial.edited_at.as_deref(), Some("2026-01-02T00:00:00Z"));
assert_eq!(
migrated
.events
.iter()
.map(|e| e.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
assert_eq!(migrated.events[1].attributes["event_id"], "authored:3");
assert_eq!(migrated.events[2].attributes["event_sequence"], "9");
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
reopened.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='authored' AND event_index=3 AND key='event_id'", [], |r| r.get::<_,String>(0))?, "raw-identity");
assert_eq!(conn.query_row("SELECT archived FROM typed_ticket_events WHERE ticket_id='authored' AND event_index=15", [], |r| r.get::<_,i64>(0))?, 1);
Ok(())
}).unwrap();
assert_eq!(backend.show("authored".into()).unwrap(), migrated);
backend
.add_event(
"authored".into(),
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "Next discussion"),
)
.unwrap();
assert_eq!(
backend
.show("authored".into())
.unwrap()
.events
.last()
.unwrap()
.event_ref,
"authored:16"
);
}
#[test]
fn ticket_posts_schema94_host_failure_rolls_back_domain_rows_ddl_and_markers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
conn.execute_batch("CREATE TRIGGER reject_host94 BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=94 BEGIN SELECT RAISE(ABORT,'injected host94 failure'); END;").unwrap();
let schema = fixture_query_rows(
&conn,
"SELECT type,name,sql FROM sqlite_schema ORDER BY type,name",
);
let tables = [
"typed_tickets",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"ticket_schema_migrations",
"__yoi_schema_migrations",
];
let rows = tables
.map(|table| fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid")));
drop(conn);
for entrypoint in 0..3 {
let error = match entrypoint {
0 => SqliteWorkspaceStore::open_for_test(&path).err().unwrap(),
1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(),
_ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(),
};
assert!(
error.to_string().contains("injected host94 failure"),
"{error}"
);
let conn = Connection::open(&path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 93);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT type,name,sql FROM sqlite_schema ORDER BY type,name"
),
schema
);
for (table, before) in tables.iter().zip(&rows) {
assert_eq!(
&fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid")),
before,
"rollback {table}"
);
}
}
let conn = Connection::open(&path).unwrap();
conn.execute_batch("DROP TRIGGER reject_host94;").unwrap();
drop(conn);
SqliteWorkspaceStore::open_for_test(&path).unwrap();
}
#[test]
fn ticket_posts_fresh_and_migrated_schema_constraints_match() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
drop(ticket_posts_schema93_fixture(&path));
let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
for table in [
"typed_tickets",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
] {
for query in [
format!(
"SELECT name,type,\"notnull\",dflt_value,pk FROM pragma_table_info('{table}') ORDER BY name"
),
format!(
"SELECT \"table\",\"from\",\"to\",on_update,on_delete FROM pragma_foreign_key_list('{table}') ORDER BY 1,2,3"
),
format!(
"SELECT i.\"unique\",i.partial,(SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list('{table}') i ORDER BY 1,2,3"
),
format!(
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' AND tbl_name='{table}' ORDER BY name"
),
] {
let expected = fresh
.with_conn(|conn| Ok(fixture_query_rows(conn, &query)))
.unwrap();
let actual = upgraded
.with_conn(|conn| Ok(fixture_query_rows(conn, &query)))
.unwrap();
assert_eq!(actual, expected, "{query}");
}
}
for store in [&fresh, &upgraded] {
store.with_conn(|conn| {
seed_migration_workspace_if_missing(conn);
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('space','guard','guard','Guard','open','task','P2','open',1)",[])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',-1,'guard:-1','comment','Initial')",[])?;
assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',0,'guard:-1','comment','Duplicate ref')",[]).is_err());
assert!(conn.execute("UPDATE typed_ticket_events SET archived=2 WHERE ticket_id='guard'",[]).is_err());
assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','missing',0,'missing:0','comment','Orphan')",[]).is_err());
Ok(())
}).unwrap();
}
}
fn seed_migration_workspace_if_missing(conn: &Connection) {
if conn
.query_row(
"SELECT COUNT(*) FROM workspaces WHERE workspace_id='space'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
== 0
{
seed_migration_workspace(conn);
}
}
#[test]
fn ticket_posts_oldest_supported_host50_preserves_content_through_full_chain() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
prepare_schema_v50(&path, Some("workspace-a"));
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_ticket_posts_legacy_content(&conn, "workspace-a");
conn.execute("UPDATE typed_tickets SET workflow_state='planning'", [])
.unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
Ok(())
})
.unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "workspace-a").unwrap();
let loaded = backend.show("authored".into()).unwrap();
assert_eq!(
loaded.document.body.as_str(),
"Current requirements after edits"
);
assert_eq!(loaded.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
loaded
.events
.iter()
.map(|event| event.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
}
@@ -265,8 +265,8 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,93);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11);
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,12);
assert_eq!(fixture_auth_rows(conn),auth_before);
for (table, rows) in preserved_tables.iter().zip(&preserved) {
let mut migrated = stored_fixture_rows(conn, table);
@@ -493,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)?,
11
12
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_history"),