diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index f5179323..c2777e4a 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -9,7 +9,6 @@ use thiserror::Error; use uuid::Uuid; const SCHEMA_VERSION: i64 = 12; -const PREVIOUS_SCHEMA_VERSION: i64 = 11; const MAX_BODY_BYTES: usize = 16 * 1024; const DOMAIN_TABLES: [&str; 5] = [ "merge_requests", @@ -37,7 +36,7 @@ impl MergeRequestState { fn parse(v: &str) -> Result { match v { - "draft" | "open" => Ok(Self::Open), + "open" => Ok(Self::Open), "merged" => Ok(Self::Merged), "closed" => Ok(Self::Closed), _ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))), @@ -1355,14 +1354,9 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { match schema_state(c)? { SchemaState::Fresh => fresh(c), SchemaState::Current(SCHEMA_VERSION) => verify(c), - SchemaState::Current(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Current), - SchemaState::Legacy(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Legacy), SchemaState::Current(v) => Err(MergeRequestError::Operation(format!( "unsupported schema {v}" ))), - SchemaState::Legacy(v) => Err(MergeRequestError::Operation(format!( - "unsupported legacy schema {v}" - ))), } } @@ -1370,26 +1364,14 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { enum SchemaState { Fresh, Current(i64), - Legacy(i64), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PreviousSchemaMarker { - Current, - Legacy, } fn schema_state(c: &Connection) -> Result { - let (current, legacy): (bool, bool) = c.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema'),EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')", + let current: bool = c.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')", [], - |r| Ok((r.get(0)?, r.get(1)?)), + |r| r.get(0), )?; - if current && legacy { - return Err(MergeRequestError::Corrupt( - "both current and legacy schema markers exist".into(), - )); - } if current { let (count, singleton, version): (i64, Option, Option) = c.query_row( "SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema", @@ -1406,22 +1388,6 @@ fn schema_state(c: &Connection) -> Result { })?; return Ok(SchemaState::Current(version)); } - if legacy { - let (count, version): (i64, Option) = c.query_row( - "SELECT COUNT(*),MAX(version) FROM merge_request_schema_migrations", - [], - |r| Ok((r.get(0)?, r.get(1)?)), - )?; - if count != 1 { - return Err(MergeRequestError::Corrupt( - "legacy schema marker must contain exactly one version".into(), - )); - } - let version = version.ok_or_else(|| { - MergeRequestError::Corrupt("legacy schema marker version is null".into()) - })?; - return Ok(SchemaState::Legacy(version)); - } let domain_tables: bool = c.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')", [], @@ -1436,214 +1402,20 @@ fn schema_state(c: &Connection) -> Result { } fn fresh(c: &Connection) -> Result<(), MergeRequestError> { let t = c.unchecked_transaction()?; - tables(&t, true)?; - t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?; + tables(&t)?; + t.execute( + "INSERT INTO merge_request_schema VALUES(1,?1)", + params![SCHEMA_VERSION], + )?; fk(&t)?; t.commit()?; Ok(()) } -fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> { - if marker { - t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")? - } +fn tables(t: &Transaction<'_>) -> Result<(), MergeRequestError> { + t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?; t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?; Ok(()) } -fn from_v11( - c: &Connection, - previous_marker: PreviousSchemaMarker, -) -> Result<(), MergeRequestError> { - let t = c.unchecked_transaction()?; - if previous_marker == PreviousSchemaMarker::Legacy { - t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?; - t.execute( - "INSERT INTO merge_request_schema VALUES(1,?1)", - params![PREVIOUS_SCHEMA_VERSION], - )?; - } - t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?; - tables(&t, false)?; - t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?; - t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?; - migrate_events(&t)?; - if previous_marker == PreviousSchemaMarker::Legacy { - t.execute("DROP TABLE merge_request_schema_migrations", [])?; - } - t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?; - fk(&t)?; - t.commit()?; - Ok(()) -} -fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> { - let attempts = { - let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?; - s.query_map([], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, String>(2)?, - r.get::<_, String>(3)?, - r.get::<_, String>(4)?, - r.get::<_, String>(5)?, - r.get::<_, String>(6)?, - r.get::<_, String>(7)?, - r.get::<_, Option>(8)?, - r.get::<_, String>(9)?, - )) - })? - .collect::, _>>()? - }; - for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts { - let req = ReviewRequestedEvent { - event_id: format!("migrated-request-{a}"), - sequence: next_seq(t, &ws, &mr)?, - subject_ref: subject.clone(), - requested_by: WorkerIdentity { - runtime_id: pr.clone(), - worker_id: pw, - }, - reviewer: WorkerIdentity { - runtime_id: pr, - worker_id: child, - }, - created_at: time(&created)?, - }; - insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?; - if status == "submitted" { - let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; - let findings = { - let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?; - s.query_map(params![ws, a], |r| { - Ok(ReviewFinding { - severity: match r.get::<_, String>(0)?.as_str() { - "blocker" => FindingSeverity::Blocker, - "major" => FindingSeverity::Major, - "minor" => FindingSeverity::Minor, - _ => FindingSeverity::Note, - }, - code: r.get(1)?, - path: r.get(2)?, - line: r.get(3)?, - body: r.get(4)?, - }) - })? - .collect::, _>>()? - }; - let rev = ReviewEvent { - event_id: format!("migrated-review-{a}"), - sequence: next_seq(t, &ws, &mr)?, - request_event_id: req.event_id, - subject_ref: subject, - decision: if row_dec == "approve" { - ReviewDecision::Approve - } else { - ReviewDecision::RequestChanges - }, - body: row_body, - findings, - reviewer: req.reviewer, - created_at: time(&row_at)?, - }; - insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)? - } else { - let at = consumed.as_deref().unwrap_or(&created); - let e = ReviewCancelledEvent { - event_id: format!("migrated-cancel-{a}"), - sequence: next_seq(t, &ws, &mr)?, - request_event_id: req.event_id, - subject_ref: subject, - reason: format!( - "legacy `{status}` review request cancelled because its capability cannot be migrated" - ), - created_at: time(at)?, - }; - insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)? - } - } - let completed = { - let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?; - q.query_map([], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, String>(2)?, - r.get::<_, Option>(3)?, - r.get::<_, Option>(4)?, - r.get::<_, Option>(5)?, - r.get::<_, Option>(6)?, - r.get::<_, Option>(7)?, - r.get::<_, Option>(8)?, - r.get::<_, Option>(9)?, - r.get::<_, String>(10)?, - r.get::<_, String>(11)?, - )) - })? - .collect::, _>>()? - }; - for ( - ws, - op, - _ticket, - target, - source, - result, - strategy, - resolution, - runtime, - worker, - updated, - mr, - ) in completed - { - let subject = source.ok_or_else(|| { - MergeRequestError::Operation(format!("completed operation {op} lacks source evidence")) - })?; - let approval:Option=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?; - let approval = approval.ok_or_else(|| { - MergeRequestError::Operation(format!( - "completed operation {op} lacks approval evidence" - )) - })?; - let e = MergeEvent { - event_id: format!("migrated-merge-{op}"), - sequence: next_seq(t, &ws, &mr)?, - operation_id: op, - approval_event_id: approval, - approved_source_ref: subject, - target_ref_before: target.ok_or_else(|| { - MergeRequestError::Operation("completed operation lacks target evidence".into()) - })?, - target_ref_after: result.ok_or_else(|| { - MergeRequestError::Operation("completed operation lacks result evidence".into()) - })?, - strategy: if strategy.as_deref() == Some("merge") { - MergeStrategy::Merge - } else { - MergeStrategy::FastForward - }, - resolution: match resolution.as_deref() { - Some("clean") => ConflictResolution::Clean, - Some("conflicts_resolved") => ConflictResolution::ConflictsResolved, - _ => ConflictResolution::None, - }, - merged_by: WorkerIdentity { - runtime_id: runtime.unwrap_or_else(|| "legacy".into()), - worker_id: worker.unwrap_or_else(|| "legacy".into()), - }, - created_at: time(&updated)?, - }; - insert_event( - t, - &ws, - &mr, - "merge", - &e, - e.created_at, - Some(&e.operation_id), - )?; - } - Ok(()) -} fn verify(c: &Connection) -> Result<(), MergeRequestError> { for n in DOMAIN_TABLES { let e: bool = c.query_row( diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index fe21a67b..96ea7583 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -301,21 +301,13 @@ fn review_revocation_invalidates_readiness() { } #[test] -fn legacy_v11_migration_preserves_review_events_and_replaces_marker() { +fn fresh_schema_uses_version_12_and_reopens_as_current() { let c = Connection::open_in_memory().unwrap(); - c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap(); c.execute_batch( - "CREATE TABLE unrelated_parent(left_id TEXT,right_id TEXT,PRIMARY KEY(left_id,right_id));CREATE TABLE unrelated_child(left_id TEXT REFERENCES unrelated_parent(left_id));", + "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));", ) .unwrap(); - let unrelated_mismatch = c - .query_row("PRAGMA foreign_key_check", [], |_| Ok(())) - .unwrap_err(); - assert!( - unrelated_mismatch - .to_string() - .contains("foreign key mismatch") - ); + merge_request::migrate(&c).unwrap(); assert_eq!( c.query_row("SELECT version FROM merge_request_schema", [], |r| { @@ -324,66 +316,26 @@ fn legacy_v11_migration_preserves_review_events_and_replaces_marker() { .unwrap(), 12 ); - let legacy_marker: bool = c - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(!legacy_marker); - let selector: Option = c - .query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0)) - .unwrap(); - assert!(selector.is_none()); - let kinds: String = c - .query_row( - "SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!( - kinds, - "review_requested,review,review_requested,review_cancelled" - ); - let old: bool = c - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(!old); + merge_request::migrate(&c).unwrap(); } #[test] -fn failed_legacy_v11_migration_rolls_back_marker_bridge() { +fn current_schema_validation_rejects_missing_tables() { let c = Connection::open_in_memory().unwrap(); c.execute_batch( - "CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(merge_request_id TEXT);", + "CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));", ) .unwrap(); - - assert!(merge_request::migrate(&c).is_err()); - for table in ["merge_request_schema_migrations", "merge_requests"] { - let exists: bool = c - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - [table], - |r| r.get(0), - ) - .unwrap(); - assert!(exists, "{table} was not rolled back"); - } - let current_marker: bool = c - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')", - [], - |r| r.get(0), - ) + merge_request::migrate(&c).unwrap(); + c.execute_batch("DROP TABLE merge_request_review_grants;") .unwrap(); - assert!(!current_marker); + + let error = merge_request::migrate(&c).unwrap_err(); + assert!(matches!( + error, + MergeRequestError::Corrupt(message) + if message == "missing `merge_request_review_grants`" + )); } #[test] diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql new file mode 100644 index 00000000..942dc3c8 --- /dev/null +++ b/crates/ticket/src/latest_schema.sql @@ -0,0 +1,122 @@ +-- 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, repository_id TEXT, ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id) +); +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); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index a355c547..ff366a1c 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -26,11 +26,7 @@ pub mod config; mod sqlite_schema; pub mod tool; -pub use sqlite_schema::{ - LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_resource_key_schema_in_transaction, - migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_through, - verify_sqlite_ticket_schema, -}; +pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema}; const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"]; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; @@ -2576,7 +2572,7 @@ impl SqliteTicketBackend { } } - /// Opens a standalone Ticket backend, applying all Ticket-owned migrations once. + /// Opens a standalone Ticket backend at the current canonical schema baseline. pub fn open(db_path: impl Into, workspace_id: impl Into) -> Result { let backend = Self::configured(db_path, workspace_id); let connection = backend.connect()?; diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 6ca773ee..d95e5cc1 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; #[derive(Clone, Copy)] struct Migration { @@ -16,38 +16,11 @@ struct Migration { apply: fn(&Connection) -> Result<()>, } -const MIGRATIONS: &[Migration] = &[ - Migration { - version: 1, - name: "create_typed_ticket_tables", - apply: create_typed_ticket_tables, - }, - Migration { - version: 2, - name: "add_ticket_repository_target", - apply: add_ticket_repository_target, - }, - Migration { - version: 3, - name: "convert_legacy_reviews_to_comments", - apply: retire_legacy_ticket_review_events, - }, - Migration { - version: 4, - name: "add_ticket_query_indexes", - apply: add_ticket_query_indexes, - }, - Migration { - version: 5, - name: "add_workspace_human_keys", - apply: add_workspace_human_keys, - }, - Migration { - version: 6, - name: "rename_workspace_resource_keys", - apply: rename_workspace_resource_keys, - }, -]; +const MIGRATIONS: &[Migration] = &[Migration { + version: LATEST_SQLITE_TICKET_SCHEMA_VERSION, + name: "ticket schema baseline", + apply: create_latest_ticket_schema, +}]; #[derive(Clone, Copy)] struct ExpectedColumn { @@ -258,30 +231,12 @@ const fn column( } } -/// Applies the Ticket crate's SQLite migrations and verifies the resulting schema. +/// Creates and verifies the Ticket crate's latest SQLite schema. /// /// This is a startup/standalone-open operation. Normal Ticket request handling must /// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL /// authority. pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { - migrate_sqlite_ticket_schema_through(connection, LATEST_SQLITE_TICKET_SCHEMA_VERSION) -} - -/// Applies Ticket migrations only through `target_version`. -/// -/// This exists for the Workspace Server's ordered migration bridge: older Server -/// migrations must materialize the Ticket schema shape they were written against -/// before the current Ticket migration is applied at the matching Server version. -#[doc(hidden)] -pub fn migrate_sqlite_ticket_schema_through( - connection: &Connection, - target_version: i64, -) -> Result<()> { - if !(1..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&target_version) { - return Err(TicketError::Sqlite(format!( - "unsupported Ticket schema migration target {target_version}" - ))); - } connection .busy_timeout(Duration::from_secs(5)) .map_err(sqlite_err)?; @@ -302,25 +257,10 @@ pub fn migrate_sqlite_ticket_schema_through( verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; let applied = load_applied_migrations(connection)?; - validate_applied_migrations(&applied)?; - - if let Some(version) = applied - .keys() - .copied() - .find(|version| *version > target_version) - { - return Err(TicketError::Sqlite(format!( - "Ticket schema version {version} is newer than requested migration target {target_version}" - ))); - } - - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= target_version) - { - if applied.contains_key(&migration.version) { - continue; - } + if applied.is_empty() { + let migration = MIGRATIONS + .first() + .ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?; (migration.apply)(connection)?; connection .execute( @@ -333,24 +273,11 @@ pub fn migrate_sqlite_ticket_schema_through( ], ) .map_err(sqlite_err)?; + } else { + validate_applied_migrations(&applied)?; } - if target_version == LATEST_SQLITE_TICKET_SCHEMA_VERSION { - verify_sqlite_ticket_schema(connection) - } else { - let applied = load_applied_migrations(connection)?; - let expected = MIGRATIONS - .iter() - .filter(|migration| migration.version <= target_version) - .map(|migration| (migration.version, migration.name.to_string())) - .collect::>(); - if applied != expected { - return Err(TicketError::Sqlite(format!( - "Ticket schema migration history does not match target version {target_version}" - ))); - } - Ok(()) - } + verify_sqlite_ticket_schema(connection) })(); match result { @@ -362,47 +289,6 @@ pub fn migrate_sqlite_ticket_schema_through( } } -/// Applies the resource-key Ticket migration inside a transaction owned by the -/// Workspace Server. The caller must provide an active transaction; this function -/// deliberately does not begin or commit one so the Ticket and Server migration -/// markers can be persisted atomically. -#[doc(hidden)] -pub fn migrate_sqlite_ticket_resource_key_schema_in_transaction( - connection: &Connection, -) -> 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)?; - let applied = load_applied_migrations(connection)?; - validate_applied_migrations(&applied)?; - if applied.contains_key(&LATEST_SQLITE_TICKET_SCHEMA_VERSION) { - return verify_sqlite_ticket_schema(connection); - } - let expected_previous = LATEST_SQLITE_TICKET_SCHEMA_VERSION - 1; - if applied.len() != expected_previous as usize || !applied.contains_key(&expected_previous) { - return Err(TicketError::Sqlite(format!( - "Ticket schema must be at version {expected_previous} before the resource-key migration" - ))); - } - let migration = MIGRATIONS - .last() - .ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".to_string()))?; - (migration.apply)(connection)?; - connection - .execute( - "INSERT INTO ticket_schema_migrations (version, name, applied_at) VALUES (?1, ?2, datetime('now'))", - params![migration.version, migration.name], - ) - .map_err(sqlite_err)?; - verify_sqlite_ticket_schema(connection) -} - /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { let mut diagnostics = Vec::new(); @@ -539,238 +425,9 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } -fn create_typed_ticket_tables(connection: &Connection) -> Result<()> { +fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { connection - .execute_batch( - r#" -CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 -); -"#, - ) - .map_err(sqlite_err) -} - -fn add_ticket_repository_target(connection: &Connection) -> Result<()> { - add_column_if_missing(connection, "typed_tickets", "repository_id", "TEXT")?; - add_column_if_missing(connection, "typed_tickets", "ref_selector", "TEXT") -} - -fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> { - // Historical prose remains visible for audit, but it is explicitly converted to a - // non-authoritative comment. Approval authority now lives only in Merge Requests. - connection - .execute_batch( - r#" - INSERT OR REPLACE INTO typed_ticket_event_attributes - (workspace_id, ticket_id, event_index, key, value) - SELECT workspace_id, ticket_id, event_index, 'legacy_event_kind', 'review' - FROM typed_ticket_events WHERE kind = 'review'; - UPDATE typed_ticket_events - SET kind = 'comment', status = NULL, heading = 'Legacy review (non-authoritative)' - WHERE kind = 'review'; - DELETE FROM typed_ticket_event_attributes - WHERE key IN ('result', 'review_result', 'status') - AND EXISTS ( - SELECT 1 FROM typed_ticket_events event - WHERE event.workspace_id = typed_ticket_event_attributes.workspace_id - AND event.ticket_id = typed_ticket_event_attributes.ticket_id - AND event.event_index = typed_ticket_event_attributes.event_index - AND event.heading = 'Legacy review (non-authoritative)' - ); - "#, - ) - .map_err(sqlite_err) -} - -fn add_ticket_query_indexes(connection: &Connection) -> Result<()> { - connection - .execute_batch( - r#" - CREATE INDEX IF NOT EXISTS typed_tickets_workspace_state_updated - ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); - CREATE INDEX IF NOT EXISTS typed_tickets_workspace_updated - ON typed_tickets(workspace_id, updated_at DESC, ticket_id); - CREATE INDEX IF NOT EXISTS typed_tickets_workspace_created - ON typed_tickets(workspace_id, created_at DESC, ticket_id); - CREATE INDEX IF NOT EXISTS typed_tickets_workspace_title - ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); - CREATE INDEX IF NOT EXISTS typed_ticket_events_workspace_kind_ticket - ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); - CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_source_kind - ON typed_ticket_relations(workspace_id, ticket_id, kind, target); - CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_target_kind - ON typed_ticket_relations(workspace_id, target, kind, ticket_id); - "#, - ) - .map_err(sqlite_err) -} - -fn add_workspace_human_keys(connection: &Connection) -> Result<()> { - connection - .execute_batch( - r#" - CREATE TABLE IF NOT EXISTS workspace_resource_human_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), - human_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, human_key) - ); - CREATE TABLE IF NOT EXISTS workspace_resource_human_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) - ); - - INSERT OR IGNORE INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at - ) - SELECT workspace_id, - 'ticket', - ticket_id, - ROW_NUMBER() OVER ( - PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC - ), - 'T-' || ROW_NUMBER() OVER ( - PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC - ), - COALESCE(created_at, updated_at) - FROM typed_tickets; - - INSERT INTO workspace_resource_human_key_counters ( - workspace_id, resource_kind, next_sequence - ) - SELECT workspace_id, 'ticket', MAX(sequence) + 1 - FROM workspace_resource_human_keys - WHERE resource_kind = 'ticket' - GROUP BY workspace_id - ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET - next_sequence = MAX(next_sequence, excluded.next_sequence); - "#, - ) - .map_err(sqlite_err) -} - -fn rename_workspace_resource_keys(connection: &Connection) -> Result<()> { - connection - .execute_batch( - r#" - ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys; - ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key; - ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters; - DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse; - CREATE INDEX idx_workspace_resource_keys_reverse - ON workspace_resource_keys(workspace_id, resource_kind, resource_key); - "#, - ) - .map_err(sqlite_err) -} - -fn add_column_if_missing( - connection: &Connection, - table: &str, - column: &str, - declaration: &str, -) -> Result<()> { - let columns = load_columns(connection, table)?; - if columns.iter().any(|found| found.name == column) { - return Ok(()); - } - connection - .execute_batch(&format!( - "ALTER TABLE {table} ADD COLUMN {column} {declaration}" - )) + .execute_batch(include_str!("latest_schema.sql")) .map_err(sqlite_err) } @@ -796,33 +453,17 @@ fn load_applied_migrations(connection: &Connection) -> Result) -> Result<()> { - for (&version, name) in applied { - let Some(expected) = MIGRATIONS - .iter() - .find(|migration| migration.version == version) - else { - return Err(TicketError::Sqlite(format!( - "unsupported Ticket schema migration version {version}; latest supported version is {LATEST_SQLITE_TICKET_SCHEMA_VERSION}" - ))); - }; - if name != expected.name { - return Err(TicketError::Sqlite(format!( - "Ticket schema migration {version} is named {name:?}, expected {:?}", - expected.name - ))); - } + let expected = BTreeMap::from([( + LATEST_SQLITE_TICKET_SCHEMA_VERSION, + MIGRATIONS[0].name.to_string(), + )]); + if applied == &expected { + Ok(()) + } else { + Err(TicketError::Sqlite(format!( + "Ticket schema migration history must contain only the canonical version {LATEST_SQLITE_TICKET_SCHEMA_VERSION} baseline marker" + ))) } - for migration in MIGRATIONS { - if applied.keys().any(|version| *version > migration.version) - && !applied.contains_key(&migration.version) - { - return Err(TicketError::Sqlite(format!( - "Ticket schema migration history has a gap at version {}", - migration.version - ))); - } - } - Ok(()) } #[derive(Debug)] @@ -1189,223 +830,16 @@ mod tests { verify_sqlite_ticket_schema(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap(); - assert_eq!(versions.len(), 6); assert_eq!( - versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), - Some(&"rename_workspace_resource_keys".to_string()) + versions, + BTreeMap::from([( + LATEST_SQLITE_TICKET_SCHEMA_VERSION, + "ticket schema baseline".to_string(), + )]) ); - } - - #[test] - fn adopts_existing_current_schema_without_losing_data() { - let connection = Connection::open_in_memory().unwrap(); - create_typed_ticket_tables(&connection).unwrap(); - add_ticket_repository_target(&connection).unwrap(); - connection - .execute( - "INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit, repository_id, ref_selector - ) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'kept', 'open', - 'task', 'medium', 'body', 'ready', 1, 'main', 'develop')", - [], - ) - .unwrap(); - connection - .execute_batch( - "INSERT INTO typed_ticket_events ( - workspace_id, ticket_id, event_index, kind, author, at, heading, body - ) VALUES ( - 'workspace-1', 'ticket-1', 0, 'comment', 'hare', - '2026-08-10T00:00:00Z', 'Evidence', 'event kept' - ); - INSERT INTO typed_ticket_event_references ( - workspace_id, ticket_id, event_index, ordinal, kind, target - ) VALUES ('workspace-1', 'ticket-1', 0, 0, 'commit', 'abc123'); - INSERT INTO typed_ticket_relations ( - workspace_id, ticket_id, kind, target, note, author, at - ) VALUES ( - 'workspace-1', 'ticket-1', 'related', 'ticket-2', 'relation kept', - 'hare', '2026-08-10T00:00:00Z' - ); - INSERT INTO typed_ticket_orchestration_plans ( - workspace_id, ticket_id, record_id, kind, note, author, at - ) VALUES ( - 'workspace-1', 'ticket-1', 'plan-1', 'waiting_capacity_note', - 'plan kept', 'hare', '2026-08-10T00:00:00Z' - ); - INSERT INTO typed_ticket_artifacts ( - workspace_id, ticket_id, relative_path, content - ) VALUES ('workspace-1', 'ticket-1', 'evidence.txt', X'6b657074');", - ) - .unwrap(); migrate_sqlite_ticket_schema(&connection).unwrap(); - - let row = connection - .query_row( - "SELECT title, repository_id, ref_selector FROM typed_tickets", - [], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }, - ) - .unwrap(); - assert_eq!(row, ("kept".into(), "main".into(), "develop".into())); - let preserved = connection - .query_row( - "SELECT - (SELECT COUNT(*) FROM typed_ticket_events), - (SELECT COUNT(*) FROM typed_ticket_event_references), - (SELECT COUNT(*) FROM typed_ticket_relations), - (SELECT COUNT(*) FROM typed_ticket_orchestration_plans), - (SELECT COUNT(*) FROM typed_ticket_artifacts)", - [], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - )) - }, - ) - .unwrap(); - assert_eq!(preserved, (1, 1, 1, 1, 1)); - } - - #[test] - fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() { - let connection = Connection::open_in_memory().unwrap(); - migrate_sqlite_ticket_schema_through(&connection, 4).unwrap(); - connection.execute_batch( - "INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit, created_at, updated_at - ) VALUES - ('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'), - ('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');" - ).unwrap(); - - migrate_sqlite_ticket_schema_through(&connection, 5).unwrap(); - let legacy_keys = connection - .prepare( - "SELECT resource_id, human_key FROM workspace_resource_human_keys - WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket' - ORDER BY sequence", - ) - .unwrap() - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!( - legacy_keys, - vec![ - ("earlier".into(), "T-1".into()), - ("later".into(), "T-2".into()) - ] - ); - let next: i64 = connection - .query_row( - "SELECT next_sequence FROM workspace_resource_human_key_counters - WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(next, 3); - - migrate_sqlite_ticket_schema(&connection).unwrap(); - let resource_keys = connection - .prepare( - "SELECT resource_id, resource_key FROM workspace_resource_keys - WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket' - ORDER BY sequence", - ) - .unwrap() - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(resource_keys, legacy_keys); - assert_eq!( - connection - .query_row( - "SELECT next_sequence FROM workspace_resource_key_counters - WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 3 - ); - for legacy_table in [ - "workspace_resource_human_keys", - "workspace_resource_human_key_counters", - ] { - assert!( - connection - .query_row( - "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1", - [legacy_table], - |_| Ok(()), - ) - .optional() - .unwrap() - .is_none(), - "{legacy_table} still exists" - ); - } - } - - #[test] - fn upgrades_legacy_schema_without_repository_target_columns() { - let connection = Connection::open_in_memory().unwrap(); - create_typed_ticket_tables(&connection).unwrap(); - connection - .execute( - "INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit - ) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'legacy', 'open', - 'task', 'medium', 'body', 'ready', 1)", - [], - ) - .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 (version, name, applied_at) - VALUES (1, 'create_typed_ticket_tables', '2026-08-10T00:00:00Z');", - ) - .unwrap(); - - migrate_sqlite_ticket_schema(&connection).unwrap(); - verify_sqlite_ticket_schema(&connection).unwrap(); - - let columns = load_columns(&connection, "typed_tickets").unwrap(); - assert!(columns.iter().any(|column| column.name == "repository_id")); - assert!(columns.iter().any(|column| column.name == "ref_selector")); - let title = connection - .query_row("SELECT title FROM typed_tickets", [], |row| { - row.get::<_, String>(0) - }) - .unwrap(); - assert_eq!(title, "legacy"); + assert_eq!(load_applied_migrations(&connection).unwrap(), versions); } #[test] @@ -1421,12 +855,32 @@ mod tests { .unwrap(); let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); - assert!( - error - .to_string() - .contains("unsupported Ticket schema migration version 99") - ); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7); + assert!(error.to_string().contains( + "migration history must contain only the canonical version 6 baseline marker" + )); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); + } + + #[test] + fn rejects_legacy_migration_marker() { + let connection = Connection::open_in_memory().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 (version, name, applied_at) + VALUES (6, 'rename_workspace_resource_keys', '2026-08-10T00:00:00Z');", + ) + .unwrap(); + + let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); + assert!(error.to_string().contains( + "migration history must contain only the canonical version 6 baseline marker" + )); + assert!(!table_exists(&connection, "typed_tickets").unwrap()); } #[test] @@ -1509,77 +963,6 @@ mod tests { verify_sqlite_ticket_schema(&connection).unwrap(); } - #[test] - fn migration_rejects_constraint_drift_and_rolls_back_version_adoption() { - let connection = Connection::open_in_memory().unwrap(); - connection - .execute_batch( - "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 (ticket_id, workspace_id) - );", - ) - .unwrap(); - - let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); - assert!(error.to_string().contains("primary-key position")); - let migration_table_exists = connection - .query_row( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ticket_schema_migrations'", - [], - |_| Ok(()), - ) - .optional() - .unwrap() - .is_some(); - assert!(!migration_table_exists); - } - - #[test] - fn legacy_review_upgrade_preserves_prose_as_non_authoritative_comment() { - let connection = Connection::open_in_memory().unwrap(); - migrate_sqlite_ticket_schema(&connection).unwrap(); - connection.execute("INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES ('workspace-1','ticket-1','ticket-1','title','open','task','medium','body','inprogress',1)",[]).unwrap(); - connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap(); - connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap(); - connection - .execute_batch( - "DROP TABLE workspace_resource_key_counters; - DROP TABLE workspace_resource_keys; - DELETE FROM ticket_schema_migrations WHERE version >= 3;", - ) - .unwrap(); - migrate_sqlite_ticket_schema(&connection).unwrap(); - let (kind,status,heading,body):(String,Option,Option,Option)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); - assert_eq!(kind, "comment"); - assert_eq!(status, None); - assert_eq!( - heading.as_deref(), - Some("Legacy review (non-authoritative)") - ); - assert_eq!(body.as_deref(), Some("legacy evidence")); - let attributes:i64=connection.query_row("SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1'",[],|row|row.get(0)).unwrap(); - assert_eq!(attributes, 1); - let legacy:String=connection.query_row("SELECT value FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND key='legacy_event_kind'",[],|row|row.get(0)).unwrap(); - assert_eq!(legacy, "review"); - } - #[test] fn concurrent_migrators_converge_on_one_version_history() { let directory = tempdir().unwrap(); @@ -1602,6 +985,6 @@ mod tests { let connection = Connection::open(database).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap(); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 1); } } diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 443cb00e..d1dbc986 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -1513,33 +1513,6 @@ mod tests { assert_eq!(revision, first.snapshot); } - #[tokio::test] - async fn migration_materializes_main_for_existing_workspace_without_config() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - crate::store::configure_sqlite(&conn).unwrap(); - crate::store::apply_migrations_through(&conn, 30).unwrap(); - conn.execute( - "INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at - ) VALUES ('legacy', 'Legacy', 'active', '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z')", - [], - ) - .unwrap(); - crate::store::persist_workspace_config_schema_bundles(&conn).unwrap(); - crate::store::materialize_main_config_entrypoint(&conn).unwrap(); - let state = load_state(&conn, "legacy").unwrap().unwrap(); - assert!( - state - .snapshot - .entries - .contains_key(&path(MAIN_CONFIG_ENTRYPOINT)) - ); - assert_eq!( - state.contract.entrypoints, - vec![path(MAIN_CONFIG_ENTRYPOINT)] - ); - } - #[test] fn exports_typescript_transport_contract() { use ts_rs::TS; diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql new file mode 100644 index 00000000..038dd530 --- /dev/null +++ b/crates/workspace-server/src/latest_schema.sql @@ -0,0 +1,1068 @@ +-- Canonical composed Workspace Server schema. New databases are created directly at this generation. +-- Ticket creates its standalone schema first; replace those empty tables with the composed authority. +DROP TABLE typed_ticket_event_attributes; +DROP TABLE typed_ticket_event_references; +DROP TABLE typed_ticket_artifacts; +DROP TABLE typed_ticket_labels; +DROP TABLE typed_ticket_orchestration_plans; +DROP TABLE typed_ticket_raw_frontmatter; +DROP TABLE typed_ticket_relations; +DROP TABLE typed_ticket_risk_flags; +DROP TABLE typed_ticket_events; +DROP TABLE typed_tickets; +DROP TABLE workspace_resource_key_counters; +DROP TABLE workspace_resource_keys; +CREATE TABLE accounts ( + account_id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), + handle TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (kind, handle) +); +CREATE TABLE api_tokens ( + token_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT +); +CREATE TABLE "artifacts" ( + workspace_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT, + PRIMARY KEY (workspace_id, artifact_id), + CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE audit_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + audit_event_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_key TEXT NOT NULL, + actor_display TEXT NOT NULL, + actor_source_kind TEXT, + actor_source_key TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + outcome TEXT NOT NULL, + request_id TEXT, + summary TEXT +); +CREATE TABLE auth_challenges ( + challenge_id TEXT PRIMARY KEY, + ceremony TEXT NOT NULL CHECK (ceremony IN ('passkey_registration', 'passkey_login')), + challenge TEXT NOT NULL UNIQUE, + user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, + rp_id TEXT NOT NULL, + origin TEXT NOT NULL, + state_json TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); +CREATE TABLE browser_sessions ( + session_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE TABLE device_login_flows ( + device_code TEXT PRIMARY KEY, + user_code TEXT NOT NULL UNIQUE, + verification_uri TEXT NOT NULL, + client_name TEXT, + user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, + api_token_id TEXT REFERENCES api_tokens(token_id) ON DELETE SET NULL, + issued_access_token TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + consumed_at TEXT +); +CREATE TABLE flow_source_revisions ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id, revision), + FOREIGN KEY (workspace_id, flow_id) + REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE +); +CREATE TABLE flow_sources ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')), + name TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id), + UNIQUE (workspace_id, source_kind, name), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE memory_staging_records ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id) +); +CREATE TABLE memory_staging_resolutions ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id, resolved_at) +); +CREATE TABLE "objective_events" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_resources" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + body TEXT NOT NULL, + media_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, resource_path), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_ticket_links" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "objectives" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE passkey_credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + public_key_cose TEXT NOT NULL, + transports_json TEXT, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_used_at TEXT +); +CREATE TABLE "repositories" ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL PRIMARY KEY, + repository_key TEXT NOT NULL + CHECK(length(repository_key) BETWEEN 1 AND 64) + CHECK(repository_key NOT GLOB '*[^a-z0-9-]*') + CHECK(substr(repository_key, 1, 1) <> '-') + CHECK(substr(repository_key, -1, 1) <> '-'), + kind TEXT NOT NULL, + provider TEXT, + uri TEXT NOT NULL, + default_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_uri TEXT NOT NULL, + source_revision INTEGER NOT NULL DEFAULT 1, + source_fingerprint TEXT NOT NULL, + observed_status TEXT NOT NULL DEFAULT 'unverified', + observed_at TEXT, + UNIQUE(workspace_id, repository_key), + UNIQUE(workspace_id, repository_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_audit_events ( + workspace_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), + resource_id TEXT NOT NULL, + result_revision INTEGER NOT NULL CHECK (result_revision >= 1), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credential_revisions ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, credential_id, revision), + FOREIGN KEY (workspace_id, credential_id) + REFERENCES repository_ssh_credentials(workspace_id, credential_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credentials ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TEXT NOT NULL, + rotated_at TEXT, + PRIMARY KEY (workspace_id, credential_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trust_revisions ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id, revision), + FOREIGN KEY (workspace_id, host_trust_id) + REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trusts ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE server_secret_versions ( + workspace_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), + encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, secret_id, revision, purpose), + FOREIGN KEY (workspace_id, secret_id, revision) + REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision) + ON DELETE CASCADE + ); +CREATE TABLE ticket_assignment_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', '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, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE ticket_assignment_ticket_tombstones ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE ticket_assignment_worker_tombstones ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE ticket_current_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', '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, + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) + ON DELETE RESTRICT, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE ticket_worker_assignment_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + operation_id TEXT, + reason TEXT, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE ticket_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE trusted_runtime_records ( + runtime_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT +, workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT); +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, + FOREIGN KEY (workspace_id, target) + 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, + repository_id TEXT, + ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE users ( + user_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE REFERENCES accounts(account_id) ON DELETE CASCADE, + handle TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workdir_create_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + repository_id TEXT NOT NULL, + selector TEXT, + requested_runtime_id TEXT, + resolved_runtime_id TEXT NOT NULL, + config_revision INTEGER NOT NULL, + config_projection_digest TEXT NOT NULL, + working_directory_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), + failure TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, cache_generation INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, operation_id), + UNIQUE (workspace_id, working_directory_id) + ); +CREATE TABLE "workdir_registry" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + creation_selector TEXT, + creation_ref TEXT, + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), + cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + current_selector TEXT, + current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES "repositories"(workspace_id, repository_id) +); +CREATE TABLE workdir_removal_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + materialization_fingerprint TEXT NOT NULL, + source_actor TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), + disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), + failure_category TEXT, + attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), + attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE "worker_control_grants" ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + relation TEXT NOT NULL, + origin TEXT NOT NULL, + permissions_json TEXT NOT NULL, + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_worker_id) + REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_worker_id) + REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE + ); +CREATE TABLE worker_create_reservations ( + workspace_id TEXT NOT NULL, + allocation_key TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + create_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'created')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT, + PRIMARY KEY (workspace_id, allocation_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE worker_diagnostics_archives ( + operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_mutation_source_proof_jtis ( + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (runtime_id, jti) + ); +CREATE TABLE worker_orphan_diagnostics ( + diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "worker_registry" ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE worker_removal_operations ( + operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0), + policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, + session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, + archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL, + diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')), + reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_retention_audit_events ( + event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, + event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_session_archives ( + archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, + policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL, expires_at TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE worker_tombstones ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, + archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE "worker_workdir_attachment_reservations" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES "workdir_registry"(workspace_id, workdir_id) ON DELETE CASCADE +); +CREATE TABLE "worker_workdir_links" ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + role TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, role), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_entries ( + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + content_type TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + PRIMARY KEY (workspace_id, path), + FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_tree_revisions ( + workspace_id TEXT NOT NULL, + revision INTEGER NOT NULL, + tree_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + PRIMARY KEY (workspace_id, revision), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_trees ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK (revision >= 0), + tree_digest TEXT NOT NULL, + schema_version INTEGER NOT NULL, + entrypoints_json TEXT NOT NULL, + decodal_version TEXT NOT NULL, + import_policy_version INTEGER NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + updated_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_create_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_memory_documents ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workspace_memory_settings ( + workspace_id TEXT PRIMARY KEY NOT NULL, + settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1), + language TEXT NOT NULL CHECK(length(trim(language)) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_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), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +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), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE workspace_worker_retention_policies ( + workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE workspace_worker_retention_policy_revisions ( + workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), + session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), + metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), + archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), + archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), + diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,policy_id,revision), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "workspaces" ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + FOREIGN KEY (owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT + ); +CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX idx_artifacts_workspace_created + ON artifacts(workspace_id, created_at DESC); +CREATE INDEX idx_browser_sessions_token_hash ON browser_sessions(token_hash); +CREATE INDEX idx_device_login_user_code ON device_login_flows(user_code); +CREATE INDEX idx_objective_events_workspace_created + ON objective_events(workspace_id, created_at DESC); +CREATE INDEX idx_objective_resources_workspace_objective + ON objective_resources(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_objective + ON objective_ticket_links(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_ticket + ON objective_ticket_links(workspace_id, ticket_id); +CREATE INDEX idx_objectives_workspace_updated + ON objectives(workspace_id, updated_at DESC); +CREATE INDEX idx_repository_secret_audit_workspace_created + ON repository_secret_audit_events(workspace_id, created_at, event_id); +CREATE INDEX idx_repository_ssh_credentials_workspace_status + ON repository_ssh_credentials(workspace_id, status, credential_id); +CREATE INDEX idx_repository_ssh_host_trusts_workspace_host + ON repository_ssh_host_trusts(workspace_id, hostname, port); +CREATE INDEX idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX idx_ticket_worker_assignments_principal + ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); +CREATE INDEX idx_trusted_runtime_records_workspace + ON trusted_runtime_records(workspace_id, revoked_at, runtime_id); +CREATE INDEX idx_typed_ticket_relations_workspace_target + ON typed_ticket_relations(workspace_id, target, at DESC); +CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); +CREATE INDEX idx_workdir_registry_workspace_updated + ON workdir_registry(workspace_id, updated_at DESC); +CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending + ON workdir_removal_operations(workspace_id, workdir_id) + WHERE state = 'pending'; +CREATE INDEX idx_workdir_removal_operations_recovery + ON workdir_removal_operations(workspace_id, state, retryable, updated_at); +CREATE INDEX idx_workdir_removal_operations_workdir + ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC); +CREATE INDEX idx_worker_mutation_source_proof_jtis_expiry + ON worker_mutation_source_proof_jtis(expires_at); +CREATE UNIQUE INDEX idx_worker_registry_workspace_runtime_worker + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX idx_workspace_config_entries_prefix + ON workspace_config_entries(workspace_id, path); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX repositories_workspace_provider_idx + ON repositories(workspace_id, provider); +CREATE INDEX ticket_current_principal_idx + ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); +CREATE UNIQUE INDEX ticket_current_singleton_role_idx + ON ticket_current_worker_assignments(workspace_id, ticket_id, role) + WHERE role IN ('orchestrator', 'coder'); +CREATE UNIQUE INDEX ticket_current_worker_role_idx + ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id) + WHERE principal_kind = 'worker'; +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id + ON worker_workdir_attachment_reservations(workspace_id, reservation_id); +CREATE INDEX worker_control_grants_controller + ON worker_control_grants( + workspace_id, controller_worker_id, revoked_at + ); +CREATE INDEX worker_control_grants_subject + ON worker_control_grants( + workspace_id, subject_worker_id, revoked_at + ); +CREATE INDEX worker_create_reservations_worker + ON worker_create_reservations(workspace_id, worker_id); +CREATE INDEX worker_registry_runtime + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at); +CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) + WHERE unlinked_at IS NULL; +CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique + ON worker_workdir_links(workspace_id, worker_id) + WHERE unlinked_at IS NULL; +CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); +CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN + INSERT INTO workspace_worker_retention_policy_revisions + (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); + INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at) + VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at); + END; +CREATE TRIGGER ticket_assignment_operations_validate_insert + BEFORE INSERT ON ticket_assignment_operations + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment operation must reference a Ticket in the same Workspace'); + END; +CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_user_principal_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_user_principal_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_workspace_agent_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_assignment_workspace_agent_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_worker_assignment_events_validate_insert + BEFORE INSERT ON ticket_worker_assignment_events + WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.assignment_id + )) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.previous_assignment_id + )) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment event must reference the same Ticket role'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_insert + BEFORE INSERT ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_update + BEFORE UPDATE OF workspace_id, ticket_id, principal_kind, principal_id, runtime_id, worker_id + ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index bcd50849..a20981e7 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -20,7 +20,6 @@ enum Command { Serve(ServeOptions), Identity(Vec), TrustRuntime(Vec), - MigrateDryRun { database: Option }, Skills(SkillsCommand), Help, } @@ -71,17 +70,6 @@ async fn run() -> Result<(), Box> { Command::Serve(options) => run_serve(options).await, Command::Identity(args) => run_identity_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args), - Command::MigrateDryRun { database } => { - let database = database.unwrap_or_else(ServerConfig::default_server_database_path); - let plan = SqliteWorkspaceStore::migration_plan(&database).map_err(|error| { - CliError(format!( - "migration dry-run failed for {}: {error}", - database.display() - )) - })?; - println!("{}", serde_json::to_string_pretty(&plan)?); - Ok(()) - } Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -96,7 +84,6 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "identity" => Ok(Command::Identity(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), - "migrate" => parse_migrate_command(rest), "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -625,32 +612,6 @@ fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result Result { - let mut dry_run = false; - let mut database = None; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--dry-run" => dry_run = true, - "--database" => { - index += 1; - database = - Some(PathBuf::from(args.get(index).ok_or_else(|| { - CliError("--database requires a path".to_string()) - })?)); - } - value => { - return Err(CliError(format!("unknown migrate option: {value}"))); - } - } - index += 1; - } - if !dry_run { - return Err(CliError("migrate currently requires --dry-run".to_string())); - } - Ok(Command::MigrateDryRun { database }) -} - fn parse_skills_command(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { print_skills_help(); @@ -770,8 +731,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] - yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -783,8 +743,7 @@ fn print_skills_help() { fn print_serve_help() { println!( - "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database ] - yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" + "yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen Listen address (default 127.0.0.1:8787)\n --config Host-level Server config path\n -h, --help Print help" ); } @@ -823,22 +782,6 @@ mod tests { assert_eq!(name, "debug-rust"); } - #[test] - fn parse_migrate_requires_dry_run_and_accepts_database_path() { - let error = parse_migrate_command(&[]).unwrap_err(); - assert_eq!(error.to_string(), "migrate currently requires --dry-run"); - let command = parse_migrate_command(&[ - "--dry-run".to_string(), - "--database".to_string(), - "/tmp/server.db".to_string(), - ]) - .unwrap(); - let Command::MigrateDryRun { database } = command else { - panic!("expected migration dry-run command"); - }; - assert_eq!(database, Some(PathBuf::from("/tmp/server.db"))); - } - #[test] fn parse_serve_accepts_listen_and_host_config() { let args = vec![ diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 55cb20a0..474abeac 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -163,101 +163,6 @@ pub enum WorkerRetentionError { Invalid(String), } -pub(crate) fn repair_worker_diagnostics_archive_table(conn: &Connection) -> crate::Result { - let existed: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_diagnostics_archives')", - [], - |row| row.get(0), - )?; - if !existed { - conn.execute_batch( - "CREATE TABLE worker_diagnostics_archives ( - operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, - committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, - FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);", - )?; - } - Ok(!existed) -} - -pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> { - conn.execute_batch(r#" - CREATE TABLE workspace_worker_retention_policy_revisions ( - workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), - session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), - metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), - archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), - archive_retention_seconds INTEGER, - diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), - diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, - PRIMARY KEY(workspace_id,policy_id,revision), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE TABLE workspace_worker_retention_policies ( - workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL, - FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE TABLE worker_removal_operations ( - operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, - workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0), - policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, - session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, - archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, - diagnostics_disposition TEXT NOT NULL, - diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL, - state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')), - reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at); - CREATE TABLE worker_session_archives ( - archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, - policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, - committed_at TEXT NOT NULL, expires_at TEXT, - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); - CREATE TABLE worker_diagnostics_archives ( - operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, - committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, - FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE TABLE worker_tombstones ( - workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, - archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE, - PRIMARY KEY(workspace_id,runtime_id,worker_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), - FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); - CREATE TABLE worker_orphan_diagnostics ( - diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL, - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE TABLE worker_retention_audit_events ( - event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, - event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, - FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); - CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN - INSERT INTO workspace_worker_retention_policy_revisions - (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) - VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); - INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at) - VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at); - END; - "#)?; - let now = Utc::now().to_rfc3339(); - conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_revisions - (workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) - SELECT workspace_id,?1,1,'archive','tombstone','forever',NULL,'purge',NULL,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?; - conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at) - SELECT workspace_id,?1,1,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?; - Ok(()) -} - impl SqliteWorkspaceStore { pub fn worker_retention_policy( &self, @@ -1724,36 +1629,4 @@ mod tests { ); assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Failed); } - - #[test] - fn old_schema_upgrade_seeds_existing_workspace() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - { - let connection = rusqlite::Connection::open(&path).unwrap(); - crate::store::configure_sqlite(&connection).unwrap(); - crate::store::apply_migrations_through(&connection, 27).unwrap(); - connection - .execute( - "INSERT INTO accounts( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', 'old', 'old')", - [], - ) - .unwrap(); - connection - .execute( - "INSERT INTO workspaces( - workspace_id, display_name, state, created_at, updated_at, owner_account_id - ) VALUES ('legacy', 'Legacy', 'active', 'old', 'old', 'owner-account')", - [], - ) - .unwrap(); - } - let reopened = SqliteWorkspaceStore::open(&path).unwrap(); - let p = reopened.worker_retention_policy("legacy").unwrap().unwrap(); - assert_eq!(p.policy_id, CONSERVATIVE_POLICY_ID); - assert_eq!(p.session_disposition, SessionDisposition::Archive); - assert_eq!(p.metadata_disposition, MetadataDisposition::Tombstone); - } } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 44f32069..efca949f 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -4,280 +4,23 @@ use std::time::Duration; use async_trait::async_trait; use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source}; -use rusqlite::{ - Connection, OpenFlags, OptionalExtension, TransactionBehavior, backup::Backup, params, -}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use worker_runtime::identity::{ - LegacyWorkerIdentityMapping, RuntimeWorkerRef, WorkerId, legacy_worker_identity_mapping_digest, -}; +use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::{Error, Result}; -const WORKSPACES_V0_COLUMNS: &[&str] = &[ - "workspace_id", - "display_name", - "state", - "created_at", - "updated_at", -]; +const LATEST_SCHEMA_VERSION: i64 = 50; -const MIGRATIONS: &[Migration] = &[ - Migration { - version: 1, - name: "workspace db canonical schema v0 bootstrap", - apply: create_schema_v0_tables, - }, - Migration { - version: 2, - name: "align legacy workspace bootstrap with schema v0", - apply: align_legacy_bootstrap_schema, - }, - Migration { - version: 3, - name: "backend worker workdir registry schema", - apply: create_worker_workdir_registry_tables, - }, - Migration { - version: 4, - name: "remove durable worker lifecycle state", - apply: remove_worker_registry_legacy_live_state_column, - }, - Migration { - version: 5, - name: "use composite worker registry keys", - apply: use_composite_worker_registry_keys, - }, - Migration { - version: 6, - name: "add workdir runtime observation states", - apply: add_workdir_runtime_observation_states, - }, - Migration { - version: 7, - name: "remove workdir registry management kind", - apply: remove_workdir_registry_management_kind_column, - }, - Migration { - version: 8, - name: "account identity and login flow schema", - apply: create_account_identity_tables, - }, - Migration { - version: 9, - name: "webauthn challenge state", - apply: add_webauthn_challenge_state, - }, - Migration { - version: 10, - name: "sqlite objective authority and memory staging import", - apply: create_objective_sqlite_authority_tables, - }, - Migration { - version: 11, - name: "sqlite memory authority documents and staging resolutions", - apply: create_memory_authority_tables, - }, - Migration { - version: 12, - name: "trusted remote runtime registry", - apply: create_trusted_runtime_registry_tables, - }, - Migration { - version: 13, - name: "objective mutation audit events", - apply: create_objective_event_tables, - }, - Migration { - version: 14, - name: "remove unused control-plane Ticket tables", - apply: remove_unused_control_plane_ticket_tables, - }, - Migration { - version: 15, - name: "separate workdir creation evidence from current revision observation", - apply: add_workdir_revision_observations, - }, - Migration { - version: 16, - name: "ticket worker current assignment authority", - apply: create_ticket_worker_assignment_tables, - }, - Migration { - version: 17, - name: "worker workspace credentials and Ticket notification outbox", - apply: create_ticket_notification_tables, - }, - Migration { - version: 18, - name: "bidirectional idempotent Ticket Worker assignments", - apply: strengthen_ticket_worker_assignments, - }, - Migration { - version: 19, - name: "atomic Ticket notification identity credentials and cursors", - apply: strengthen_ticket_notifications, - }, - Migration { - version: 20, - name: "reconcile Workdir revision and crash safe Worker lifecycle reservations", - apply: strengthen_ticket_assignment_lifecycle_reservations, - }, - Migration { - version: 21, - name: "remove per-Worker Workspace credentials", - apply: remove_worker_workspace_credentials, - }, - Migration { - version: 22, - name: "drop Ticket notification outbox", - apply: drop_ticket_notification_tables, - }, - Migration { - version: 23, - name: "enforce exclusive active Worker Workdir attachments", - apply: enforce_exclusive_worker_workdir_attachments, - }, - Migration { - version: 24, - name: "create Worker Workdir attachment reservations", - apply: create_worker_workdir_attachment_reservations, - }, - Migration { - version: 25, - name: "create Flow source authority", - apply: create_flow_source_authority, - }, - Migration { - version: 26, - name: "remove Backend-owned Flow runtime authority", - apply: remove_backend_flow_runtime_authority, - }, - Migration { - version: 27, - name: "scope Repository identity and references by Workspace", - apply: scope_repository_identity_by_workspace, - }, - Migration { - version: 28, - name: "create Worker retention authority", - apply: crate::retention::create_worker_retention_tables, - }, - Migration { - version: 29, - name: "create Worker mutation source proof replay guard", - apply: create_worker_mutation_source_proof_replay_guard, - }, - Migration { - version: 30, - name: "create Workspace virtual config source authority", - apply: create_workspace_config_source_authority, - }, - Migration { - version: 31, - name: "materialize required main.dcdl Workspace config entrypoint", - apply: materialize_main_config_entrypoint, - }, - Migration { - version: 32, - name: "persist Workspace config schema contribution bundles", - apply: persist_workspace_config_schema_bundles, - }, - Migration { - version: 33, - name: "create durable Runtime Worker control grants", - apply: create_worker_control_grant_authority, - }, - Migration { - version: 34, - name: "create Worker control delegation operation authority", - apply: create_worker_control_delegation_operation_authority, - }, - Migration { - version: 35, - name: "remove Worker control delegation authority", - apply: remove_worker_control_delegation_authority, - }, - Migration { - version: 36, - name: "add Objective query indexes", - apply: add_objective_query_indexes, - }, - Migration { - version: 37, - name: "promote Workspace Worker UUIDv7 identity", - apply: apply_workspace_worker_uuid_identity_migration, - }, - Migration { - version: 38, - name: "add Workspace resource human keys", - apply: add_workspace_resource_human_keys, - }, - Migration { - version: 39, - name: "enforce Workspace resource foreign keys", - apply: enforce_workspace_resource_foreign_keys, - }, - Migration { - version: 40, - name: "create atomic Workspace catalog operations", - apply: create_workspace_catalog_operations, - }, - Migration { - version: 41, - name: "rename Workspace resource keys", - apply: verify_workspace_resource_key_schema, - }, - Migration { - version: 42, - name: "create Workspace Memory settings authority", - apply: create_workspace_memory_settings_authority, - }, - Migration { - version: 43, - name: "generalize Ticket assignments to role principals", - apply: generalize_ticket_role_assignments, - }, - Migration { - version: 44, - name: "create Repository source authority", - apply: create_repository_source_authority, - }, - Migration { - version: 45, - name: "create Workdir create operations", - apply: create_workdir_create_operations, - }, - Migration { - version: 46, - name: "create Workspace Repository SSH secret authority", - apply: create_repository_ssh_secret_authority, - }, - Migration { - version: 47, - name: "bind Workdir create repository access evidence", - apply: bind_workdir_create_repository_access_evidence, - }, - Migration { - version: 48, - name: "require one account owner for every Workspace", - apply: require_workspace_account_owner, - }, - Migration { - version: 49, - name: "create durable Workdir removal operations", - apply: crate::workdir_removal::create_workdir_removal_operations, - }, - Migration { - version: 50, - name: "replace public Repository ids with immutable Workspace keys", - apply: migrate_repository_identity_to_keys, - }, -]; +const MIGRATIONS: &[Migration] = &[Migration { + version: LATEST_SCHEMA_VERSION, + name: "workspace schema baseline", + apply: create_latest_workspace_schema, +}]; struct Migration { version: i64, @@ -285,17 +28,6 @@ struct Migration { apply: fn(&Connection) -> Result<()>, } -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct WorkspaceStoreMigrationPlan { - pub current_schema_version: i64, - pub target_schema_version: i64, - pub migration_required: bool, - pub worker_count: usize, - pub mapping_digest: String, - pub mappings: Vec, - pub repairs: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRecord { pub workspace_id: String, @@ -1282,93 +1014,6 @@ pub struct SqliteWorkspaceStore { } impl SqliteWorkspaceStore { - pub fn migration_plan(path: impl AsRef) -> Result { - let path = path.as_ref(); - let source = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, - )?; - let current_schema_version = current_schema_version(&source)?; - let target_schema_version = MIGRATIONS - .last() - .map(|migration| i64::from(migration.version)) - .unwrap_or(current_schema_version); - let mut repairs = Vec::new(); - if current_schema_version < 37 && !table_exists(&source, "worker_diagnostics_archives")? { - repairs.push("create missing worker_diagnostics_archives table".to_string()); - } - - if current_schema_version < 50 && table_exists(&source, "repositories")? { - let repository_count: i64 = - source.query_row("SELECT COUNT(*) FROM repositories", [], |row| row.get(0))?; - if repository_count > 0 { - repairs.push(format!( - "replace {repository_count} legacy public Repository id(s) with UUIDv7 internal ids, retain each prior id as repository_key, and retire free-form Repository names" - )); - } - } - - let mut candidate = Connection::open_in_memory()?; - { - let backup = Backup::new(&source, &mut candidate)?; - backup.run_to_completion(5, Duration::from_millis(10), None)?; - } - configure_sqlite(&candidate)?; - let mappings = if current_schema_version < 37 { - apply_migrations_through(&candidate, 36)?; - let tx = candidate.unchecked_transaction()?; - crate::retention::repair_worker_diagnostics_archive_table(&tx)?; - let mappings = promote_workspace_worker_uuid_identity(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (37, ?1)", - ["promote Workspace Worker UUIDv7 identity"], - )?; - tx.commit()?; - mappings - } else { - Vec::new() - }; - apply_migrations_through(&candidate, 38)?; - let assignment_worker_tombstone_repairs = - legacy_assignment_worker_tombstone_repairs(&candidate)?.len(); - if assignment_worker_tombstone_repairs > 0 { - repairs.push(format!( - "materialize {assignment_worker_tombstone_repairs} legacy Ticket assignment Worker tombstone(s)" - )); - } - apply_migrations_through(&candidate, 42)?; - if current_schema_version < 43 { - let stale_current = repairable_legacy_current_ticket_assignment_count(&candidate)?; - if stale_current > 0 { - repairs.push(format!( - "clear {stale_current} tombstoned legacy current Ticket assignment pointer(s)" - )); - } - } - apply_migrations_through(&candidate, i64::MAX)?; - ticket::migrate_sqlite_ticket_schema(&candidate)?; - merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?; - validate_workspace_resource_references(&candidate)?; - let foreign_key_failures: i64 = - candidate.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "migration dry-run found {foreign_key_failures} foreign key violation(s)" - ))); - } - Ok(WorkspaceStoreMigrationPlan { - current_schema_version, - target_schema_version, - migration_required: current_schema_version < target_schema_version, - worker_count: mappings.len(), - mapping_digest: legacy_worker_identity_mapping_digest(&mappings), - mappings, - repairs, - }) - } - pub fn open(path: impl AsRef) -> Result { let conn = Connection::open(path)?; Self::from_connection(conn) @@ -1380,13 +1025,13 @@ impl SqliteWorkspaceStore { pub fn from_connection(conn: Connection) -> Result { configure_sqlite(&conn)?; - apply_migrations(&conn) - .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; ticket::migrate_sqlite_ticket_schema(&conn) .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; merge_request::migrate(&conn).map_err(|error| { Error::Store(format!("Merge Request schema verification failed: {error}")) })?; + apply_migrations(&conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; validate_workspace_resource_references(&conn)?; verify_workspace_resource_constraints(&conn)?; Ok(Self { @@ -6124,532 +5769,6 @@ fn read_workdir_registry_record( }) } -fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS worker_registry ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - display_name TEXT NOT NULL, - profile TEXT, - retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), - transcript_ref TEXT, - session_ref TEXT, - summary_ref TEXT, - diagnostics_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS workdir_registry ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - selector TEXT, - resolved_commit TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), - cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS worker_workdir_links ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - workdir_id TEXT NOT NULL, - role TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role), - FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated - ON worker_registry(workspace_id, updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated - ON workdir_registry(workspace_id, updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker - ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC); -"#, - )?; - Ok(()) -} - -fn add_webauthn_challenge_state(conn: &Connection) -> Result<()> { - if !column_exists(conn, "auth_challenges", "state_json")? { - conn.execute_batch("ALTER TABLE auth_challenges ADD COLUMN state_json TEXT;")?; - } - Ok(()) -} - -fn create_objective_sqlite_authority_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -PRAGMA foreign_keys = OFF; -ALTER TABLE objective_ticket_links RENAME TO objective_ticket_links_old; -CREATE TABLE objective_ticket_links ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - kind TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (objective_id, ticket_id, kind), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (objective_id) REFERENCES objectives(objective_id) ON DELETE CASCADE -); -INSERT OR IGNORE INTO objective_ticket_links (workspace_id, objective_id, ticket_id, kind, created_at) - SELECT workspace_id, objective_id, ticket_id, kind, created_at FROM objective_ticket_links_old; -DROP TABLE objective_ticket_links_old; -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS objective_resources ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - resource_path TEXT NOT NULL, - body TEXT NOT NULL, - media_type TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (objective_id, resource_path), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (objective_id) REFERENCES objectives(objective_id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS memory_staging_records ( - workspace_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - raw_json TEXT NOT NULL, - source_path TEXT, - imported_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, candidate_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -"#, - )?; - Ok(()) -} - -fn create_memory_authority_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS workspace_memory_documents ( - workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS memory_staging_resolutions ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - candidate_id TEXT NOT NULL, - action TEXT NOT NULL, - reason TEXT NOT NULL, - affected_refs_json TEXT NOT NULL, - staging_raw_json TEXT NOT NULL, - source_path TEXT, - imported_at TEXT NOT NULL, - resolved_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, candidate_id, resolved_at) -); -"#, - )?; - Ok(()) -} - -fn remove_unused_control_plane_ticket_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -DROP TABLE IF EXISTS ticket_target_paths; -DROP TABLE IF EXISTS ticket_worker_links; -DROP TABLE IF EXISTS ticket_targets; -DROP TABLE IF EXISTS ticket_relations; -DROP TABLE IF EXISTS ticket_events; -DROP TABLE IF EXISTS tickets; -"#, - )?; - Ok(()) -} - -fn add_workdir_revision_observations(conn: &Connection) -> Result<()> { - if column_exists(conn, "workdir_registry", "selector")? - && !column_exists(conn, "workdir_registry", "creation_selector")? - { - conn.execute_batch( - "ALTER TABLE workdir_registry RENAME COLUMN selector TO creation_selector;", - )?; - } - if column_exists(conn, "workdir_registry", "resolved_commit")? - && !column_exists(conn, "workdir_registry", "creation_ref")? - { - conn.execute_batch( - "ALTER TABLE workdir_registry RENAME COLUMN resolved_commit TO creation_ref;", - )?; - } - if !column_exists(conn, "workdir_registry", "current_selector")? { - conn.execute_batch("ALTER TABLE workdir_registry ADD COLUMN current_selector TEXT;")?; - } - if !column_exists(conn, "workdir_registry", "current_ref")? { - conn.execute_batch("ALTER TABLE workdir_registry ADD COLUMN current_ref TEXT;")?; - } - Ok(()) -} - -fn create_ticket_worker_assignment_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS ticket_worker_assignments ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - assigned_by TEXT NOT NULL, - assigned_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, assignment_id), - UNIQUE (workspace_id, ticket_id, assignment_id) -); - -CREATE TABLE IF NOT EXISTS ticket_current_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id), - FOREIGN KEY (workspace_id, ticket_id, assignment_id) - REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) - ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ticket_worker_assignment_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - 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, - PRIMARY KEY (workspace_id, event_id) -); - -CREATE INDEX IF NOT EXISTS idx_ticket_assignments_worker - ON ticket_worker_assignments(workspace_id, runtime_id, worker_id, assigned_at DESC); -CREATE INDEX IF NOT EXISTS idx_ticket_assignment_events_ticket - ON ticket_worker_assignment_events(workspace_id, ticket_id, created_at DESC); -"#, - )?; - Ok(()) -} - -fn create_ticket_notification_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS worker_workspace_credentials ( - credential_id TEXT PRIMARY KEY, - token TEXT NOT NULL UNIQUE, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - runtime_id TEXT NOT NULL, - worker_id TEXT, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS ticket_notification_outbox ( - notification_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - event_sequence INTEGER NOT NULL, - source_runtime_id TEXT NOT NULL, - source_worker_id TEXT NOT NULL, - previous_state TEXT NOT NULL, - current_state TEXT NOT NULL, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS ticket_notification_deliveries ( - notification_id TEXT NOT NULL REFERENCES ticket_notification_outbox(notification_id) ON DELETE CASCADE, - recipient_runtime_id TEXT NOT NULL, - recipient_worker_id TEXT NOT NULL, - recipient_kind TEXT NOT NULL CHECK (recipient_kind IN ('assigned', 'orchestrator')), - attempts INTEGER NOT NULL DEFAULT 0, - delivered_at TEXT, - last_error TEXT, - PRIMARY KEY (notification_id, recipient_runtime_id, recipient_worker_id) -); - -CREATE INDEX IF NOT EXISTS idx_ticket_notification_pending - ON ticket_notification_deliveries(delivered_at, attempts); -"#, - )?; - Ok(()) -} - -fn strengthen_ticket_worker_assignments(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -ALTER TABLE ticket_current_worker_assignments RENAME TO ticket_current_worker_assignments_v16; - -CREATE TABLE ticket_current_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id), - UNIQUE (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, ticket_id, assignment_id) - REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) - ON DELETE CASCADE -); - -INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at -) -SELECT current.workspace_id, current.ticket_id, current.assignment_id, - assignment.runtime_id, assignment.worker_id, current.updated_at -FROM ticket_current_worker_assignments_v16 AS current -JOIN ticket_worker_assignments AS assignment - ON assignment.workspace_id = current.workspace_id - AND assignment.ticket_id = current.ticket_id - AND assignment.assignment_id = current.assignment_id; - -DROP TABLE ticket_current_worker_assignments_v16; - -CREATE TABLE ticket_assignment_operations ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - operation_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), - ticket_id TEXT NOT NULL, - runtime_id TEXT, - worker_id TEXT, - assignment_id TEXT, - expected_assignment_id TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, operation_id) -); -"#, - )?; - Ok(()) -} - -fn strengthen_ticket_notifications(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -ALTER TABLE worker_workspace_credentials ADD COLUMN expires_at TEXT; -ALTER TABLE worker_workspace_credentials ADD COLUMN revoked_at TEXT; -ALTER TABLE ticket_notification_outbox ADD COLUMN event_kind TEXT NOT NULL DEFAULT 'comment'; -ALTER TABLE ticket_notification_outbox ADD COLUMN source_operation_kind TEXT NOT NULL DEFAULT 'unknown'; -ALTER TABLE ticket_notification_outbox ADD COLUMN source_actor_role TEXT NOT NULL DEFAULT 'worker'; -ALTER TABLE ticket_notification_outbox ADD COLUMN source_assignment_id TEXT; - -CREATE TABLE ticket_notification_cursors ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - last_event_index INTEGER NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id, runtime_id, worker_id) -); -"#, - )?; - Ok(()) -} - -fn strengthen_ticket_assignment_lifecycle_reservations(conn: &Connection) -> Result<()> { - if !column_exists(conn, "ticket_assignment_operations", "request_fingerprint")? { - conn.execute_batch( - "ALTER TABLE ticket_assignment_operations ADD COLUMN request_fingerprint TEXT;", - )?; - } - // The Workdir and Ticket branches both used schema version 15 before - // integration. A database that ran the Ticket branch through v19 still - // needs the Workdir revision projection while already having the lifecycle - // reservation column. Reconcile both shapes at the combined v20 boundary. - add_workdir_revision_observations(conn)?; - Ok(()) -} - -fn remove_worker_workspace_credentials(conn: &Connection) -> Result<()> { - conn.execute_batch("DROP TABLE IF EXISTS worker_workspace_credentials;")?; - Ok(()) -} - -fn drop_ticket_notification_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -DROP TABLE IF EXISTS ticket_notification_cursors; -DROP TABLE IF EXISTS ticket_notification_deliveries; -DROP TABLE IF EXISTS ticket_notification_outbox; -"#, - )?; - Ok(()) -} - -fn enforce_exclusive_worker_workdir_attachments(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE UNIQUE INDEX IF NOT EXISTS ux_worker_workdir_links_active_worker - ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id) - WHERE unlinked_at IS NULL; -CREATE UNIQUE INDEX IF NOT EXISTS ux_worker_workdir_links_active_workdir - ON worker_workdir_links(workspace_id, workdir_id) - WHERE unlinked_at IS NULL; -"#, - )?; - Ok(()) -} - -fn create_worker_workdir_attachment_reservations(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS worker_workdir_attachment_reservations ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - reservation_id TEXT NOT NULL, - reserved_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id) -); -CREATE UNIQUE INDEX IF NOT EXISTS ux_worker_workdir_attachment_reservation_id - ON worker_workdir_attachment_reservations(workspace_id, reservation_id); -"#, - )?; - Ok(()) -} - -fn create_objective_event_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS objective_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, - event_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - body_md TEXT, - created_at TEXT NOT NULL -); -"#, - )?; - Ok(()) -} - -fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS trusted_runtime_records ( - runtime_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT -); -"#, - )?; - Ok(()) -} - -fn create_account_identity_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS accounts ( - account_id TEXT PRIMARY KEY, - kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), - handle TEXT NOT NULL, - display_name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (kind, handle) -); - -CREATE TABLE IF NOT EXISTS users ( - user_id TEXT PRIMARY KEY, - account_id TEXT NOT NULL UNIQUE REFERENCES accounts(account_id) ON DELETE CASCADE, - handle TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS passkey_credentials ( - credential_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - public_key_cose TEXT NOT NULL, - transports_json TEXT, - sign_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - last_used_at TEXT -); - -CREATE TABLE IF NOT EXISTS auth_challenges ( - challenge_id TEXT PRIMARY KEY, - ceremony TEXT NOT NULL CHECK (ceremony IN ('passkey_registration', 'passkey_login')), - challenge TEXT NOT NULL UNIQUE, - user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, - rp_id TEXT NOT NULL, - origin TEXT NOT NULL, - state_json TEXT, - expires_at TEXT NOT NULL, - created_at TEXT NOT NULL, - consumed_at TEXT -); - -CREATE TABLE IF NOT EXISTS browser_sessions ( - session_id TEXT PRIMARY KEY, - token_hash TEXT NOT NULL UNIQUE, - user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - revoked_at TEXT -); - -CREATE TABLE IF NOT EXISTS api_tokens ( - token_id TEXT PRIMARY KEY, - token_hash TEXT NOT NULL UNIQUE, - user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - label TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT, - revoked_at TEXT, - last_used_at TEXT -); - -CREATE TABLE IF NOT EXISTS device_login_flows ( - device_code TEXT PRIMARY KEY, - user_code TEXT NOT NULL UNIQUE, - verification_uri TEXT NOT NULL, - client_name TEXT, - user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, - api_token_id TEXT REFERENCES api_tokens(token_id) ON DELETE SET NULL, - issued_access_token TEXT, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - approved_at TEXT, - consumed_at TEXT -); - -CREATE INDEX IF NOT EXISTS idx_browser_sessions_token_hash ON browser_sessions(token_hash); -CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); -CREATE INDEX IF NOT EXISTS idx_device_login_user_code ON device_login_flows(user_code); -"#, - )?; - if !column_exists(conn, "workspaces", "owner_account_id")? { - conn.execute_batch( - "ALTER TABLE workspaces ADD COLUMN owner_account_id TEXT REFERENCES accounts(account_id) ON DELETE SET NULL;", - )?; - } - Ok(()) -} - pub(crate) fn configure_sqlite(conn: &Connection) -> Result<()> { conn.busy_timeout(Duration::from_millis(5_000))?; conn.execute_batch( @@ -6667,807 +5786,8 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( Ok(()) } -fn create_flow_source_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE flow_sources ( - workspace_id TEXT NOT NULL, - flow_id TEXT NOT NULL, - source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')), - name TEXT NOT NULL, - path TEXT NOT NULL, - content TEXT NOT NULL, - content_digest TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, flow_id), - UNIQUE (workspace_id, source_kind, name), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -CREATE TABLE flow_source_revisions ( - workspace_id TEXT NOT NULL, - flow_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), - content TEXT NOT NULL, - content_digest TEXT NOT NULL, - definition_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, flow_id, revision), - FOREIGN KEY (workspace_id, flow_id) - REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE -); -"#, - )?; - Ok(()) -} - -fn remove_backend_flow_runtime_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -DROP TABLE IF EXISTS flow_events; -DROP TABLE IF EXISTS flow_transition_attempts; -DROP TABLE IF EXISTS flow_instances; -"#, - )?; - Ok(()) -} - -fn scope_repository_identity_by_workspace(conn: &Connection) -> Result<()> { - validate_workspace_resource_references(conn)?; - conn.execute_batch( - r#" -CREATE TABLE repositories_v27 ( - workspace_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - name TEXT NOT NULL, - kind TEXT NOT NULL, - provider TEXT, - uri TEXT NOT NULL, - default_ref TEXT, - auth_ref_kind TEXT, - auth_ref_key TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, repository_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO repositories_v27 ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at -) -SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at -FROM repositories; - -CREATE TABLE artifacts_v27 ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - artifact_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - uri TEXT NOT NULL, - media_type TEXT, - sha256 TEXT, - size_bytes INTEGER, - summary TEXT, - created_at TEXT NOT NULL, - created_by_kind TEXT NOT NULL, - created_by_key TEXT NOT NULL, - created_by_display TEXT NOT NULL, - created_by_source_kind TEXT, - created_by_source_key TEXT, - ticket_id TEXT, - objective_id TEXT, - event_id TEXT, - worker_ref_kind TEXT, - worker_ref_key TEXT, - worker_display TEXT, - repository_id TEXT, - source_kind TEXT, - source_revision TEXT, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories_v27(workspace_id, repository_id) -); -INSERT INTO artifacts_v27 ( - workspace_id, artifact_id, kind, uri, media_type, sha256, size_bytes, summary, - created_at, created_by_kind, created_by_key, created_by_display, - created_by_source_kind, created_by_source_key, ticket_id, objective_id, event_id, - worker_ref_kind, worker_ref_key, worker_display, repository_id, source_kind, - source_revision -) -SELECT workspace_id, artifact_id, kind, uri, media_type, sha256, size_bytes, summary, - created_at, created_by_kind, created_by_key, created_by_display, - created_by_source_kind, created_by_source_key, ticket_id, objective_id, event_id, - worker_ref_kind, worker_ref_key, worker_display, repository_id, source_kind, - source_revision -FROM artifacts; - -CREATE TABLE workdir_registry_v27 ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - creation_selector TEXT, - creation_ref TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), - cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - current_selector TEXT, - current_ref TEXT, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories_v27(workspace_id, repository_id) -); -INSERT INTO workdir_registry_v27 ( - workspace_id, workdir_id, runtime_id, repository_id, - creation_selector, creation_ref, current_selector, current_ref, - materialization_status, cleanliness, created_at, updated_at -) -SELECT workspace_id, workdir_id, runtime_id, repository_id, - creation_selector, creation_ref, current_selector, current_ref, - materialization_status, cleanliness, created_at, updated_at -FROM workdir_registry; - -CREATE TABLE worker_workdir_links_v27 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - workdir_id TEXT NOT NULL, - role TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role), - FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry_v27(workspace_id, workdir_id) ON DELETE CASCADE -); -INSERT INTO worker_workdir_links_v27 ( - workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at -) -SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at -FROM worker_workdir_links; - -CREATE TABLE worker_workdir_attachment_reservations_v27 ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - reservation_id TEXT NOT NULL, - reserved_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry_v27(workspace_id, workdir_id) ON DELETE CASCADE -); -INSERT INTO worker_workdir_attachment_reservations_v27 ( - workspace_id, workdir_id, reservation_id, reserved_at -) -SELECT workspace_id, workdir_id, reservation_id, reserved_at -FROM worker_workdir_attachment_reservations; - -DROP TABLE worker_workdir_links; -DROP TABLE worker_workdir_attachment_reservations; -DROP TABLE workdir_registry; -DROP TABLE artifacts; -DROP TABLE repositories; -ALTER TABLE repositories_v27 RENAME TO repositories; -ALTER TABLE artifacts_v27 RENAME TO artifacts; -ALTER TABLE workdir_registry_v27 RENAME TO workdir_registry; -ALTER TABLE worker_workdir_links_v27 RENAME TO worker_workdir_links; -ALTER TABLE worker_workdir_attachment_reservations_v27 - RENAME TO worker_workdir_attachment_reservations; - -CREATE INDEX idx_workdir_registry_workspace_updated - ON workdir_registry(workspace_id, updated_at DESC); -CREATE INDEX idx_worker_workdir_links_worker - ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC); -CREATE UNIQUE INDEX ux_worker_workdir_links_active_worker - ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id) - WHERE unlinked_at IS NULL; -CREATE UNIQUE INDEX ux_worker_workdir_links_active_workdir - ON worker_workdir_links(workspace_id, workdir_id) - WHERE unlinked_at IS NULL; -CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id - ON worker_workdir_attachment_reservations(workspace_id, reservation_id); -"#, - )?; - Ok(()) -} - -fn create_repository_source_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - ALTER TABLE repositories ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'invalid'; - ALTER TABLE repositories ADD COLUMN source_uri TEXT NOT NULL DEFAULT ''; - ALTER TABLE repositories ADD COLUMN source_revision INTEGER NOT NULL DEFAULT 1; - ALTER TABLE repositories ADD COLUMN source_fingerprint TEXT NOT NULL DEFAULT ''; - ALTER TABLE repositories ADD COLUMN observed_status TEXT NOT NULL DEFAULT 'unverified'; - ALTER TABLE repositories ADD COLUMN observed_at TEXT; - "#, - )?; - - let legacy = { - let mut stmt = conn.prepare( - "SELECT workspace_id, repository_id, uri FROM repositories ORDER BY workspace_id, repository_id", - )?; - stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })? - .collect::, _>>()? - }; - for (workspace_id, repository_id, uri) in legacy { - let source = crate::repository_source::classify_legacy_repository_source(&uri); - let fingerprint = crate::repository_source::repository_source_fingerprint(&source); - let observed_status = if source.kind == workspace_api::RepositorySourceKind::Invalid { - RepositoryObservedStatus::Invalid - } else { - RepositoryObservedStatus::Unverified - }; - conn.execute( - r#"UPDATE repositories - SET source_kind = ?3, - source_uri = ?4, - source_revision = 1, - source_fingerprint = ?5, - observed_status = ?6, - observed_at = NULL - WHERE workspace_id = ?1 AND repository_id = ?2"#, - params![ - workspace_id, - repository_id, - source.kind.as_str(), - source.uri, - fingerprint, - observed_status.as_str(), - ], - )?; - } - Ok(()) -} - -fn create_workdir_create_operations(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE workdir_create_operations ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - request_fingerprint TEXT NOT NULL, - repository_id TEXT NOT NULL, - selector TEXT, - requested_runtime_id TEXT, - resolved_runtime_id TEXT NOT NULL, - config_revision INTEGER NOT NULL, - config_projection_digest TEXT NOT NULL, - working_directory_id TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), - failure TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, operation_id), - UNIQUE (workspace_id, working_directory_id) - ); - "#, - )?; - Ok(()) -} - -fn create_repository_ssh_secret_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE repository_ssh_credentials ( - workspace_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - name TEXT NOT NULL, - public_key_algorithm TEXT NOT NULL, - public_key_fingerprint TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision >= 1), - status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), - created_at TEXT NOT NULL, - rotated_at TEXT, - PRIMARY KEY (workspace_id, credential_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TABLE repository_ssh_credential_revisions ( - workspace_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), - public_key_algorithm TEXT NOT NULL, - public_key_fingerprint TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, credential_id, revision), - FOREIGN KEY (workspace_id, credential_id) - REFERENCES repository_ssh_credentials(workspace_id, credential_id) - ON DELETE CASCADE - ); - CREATE TABLE server_secret_versions ( - workspace_id TEXT NOT NULL, - secret_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), - purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), - encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), - nonce BLOB NOT NULL CHECK (length(nonce) = 12), - ciphertext BLOB NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, secret_id, revision, purpose), - FOREIGN KEY (workspace_id, secret_id, revision) - REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision) - ON DELETE CASCADE - ); - CREATE TABLE repository_ssh_host_trusts ( - workspace_id TEXT NOT NULL, - host_trust_id TEXT NOT NULL, - hostname TEXT NOT NULL, - port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), - key_algorithm TEXT NOT NULL, - host_key TEXT NOT NULL, - fingerprint TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision >= 1), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, host_trust_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TABLE repository_ssh_host_trust_revisions ( - workspace_id TEXT NOT NULL, - host_trust_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), - hostname TEXT NOT NULL, - port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), - key_algorithm TEXT NOT NULL, - host_key TEXT NOT NULL, - fingerprint TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, host_trust_id, revision), - FOREIGN KEY (workspace_id, host_trust_id) - REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) - ON DELETE CASCADE - ); - CREATE TABLE repository_secret_operations ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - request_fingerprint TEXT NOT NULL, - resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), - resource_id TEXT NOT NULL, - result_revision INTEGER NOT NULL CHECK (result_revision >= 1), - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TABLE repository_secret_audit_events ( - workspace_id TEXT NOT NULL, - event_id TEXT NOT NULL, - kind TEXT NOT NULL, - resource_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision >= 1), - actor_account_id TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, event_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE INDEX idx_repository_ssh_credentials_workspace_status - ON repository_ssh_credentials(workspace_id, status, credential_id); - CREATE INDEX idx_repository_ssh_host_trusts_workspace_host - ON repository_ssh_host_trusts(workspace_id, hostname, port); - CREATE INDEX idx_repository_secret_audit_workspace_created - ON repository_secret_audit_events(workspace_id, created_at, event_id); - "#, - )?; - Ok(()) -} - -fn bind_workdir_create_repository_access_evidence(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - ALTER TABLE workdir_registry ADD COLUMN creation_tree TEXT; - ALTER TABLE workdir_registry ADD COLUMN current_tree TEXT; - ALTER TABLE workdir_registry ADD COLUMN observed_at_epoch_seconds INTEGER; - ALTER TABLE workdir_create_operations ADD COLUMN source_kind TEXT; - ALTER TABLE workdir_create_operations ADD COLUMN source_uri TEXT; - ALTER TABLE workdir_create_operations ADD COLUMN source_revision INTEGER; - ALTER TABLE workdir_create_operations ADD COLUMN source_fingerprint TEXT; - ALTER TABLE workdir_create_operations ADD COLUMN credential_id TEXT; - ALTER TABLE workdir_create_operations ADD COLUMN credential_revision INTEGER; - ALTER TABLE workdir_create_operations ADD COLUMN host_trust_id TEXT; - ALTER TABLE workdir_create_operations ADD COLUMN host_trust_revision INTEGER; - ALTER TABLE workdir_create_operations ADD COLUMN repository_access_mode TEXT; - ALTER TABLE workdir_create_operations - ADD COLUMN cache_generation INTEGER NOT NULL DEFAULT 0; - "#, - )?; - Ok(()) -} - -fn normalize_schema_sql(sql: &str) -> String { - sql.split_ascii_whitespace() - .collect::>() - .join(" ") - .to_ascii_lowercase() -} - -fn migrate_repository_identity_to_keys(conn: &Connection) -> Result<()> { - const REPOSITORY_REFERENCE_TABLES: &[&str] = &[ - "artifacts", - "merge_requests", - "typed_tickets", - "workdir_create_operations", - "workdir_registry", - "workdir_removal_operations", - ]; - - // Read-only preflight every legacy public id and every persisted relational - // reference before creating the mapping or mutating authority. - let legacy_repositories = { - let mut stmt = conn.prepare( - "SELECT workspace_id, repository_id FROM repositories ORDER BY workspace_id, repository_id", - )?; - stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? - .collect::, _>>()? - }; - for (workspace_id, repository_key) in &legacy_repositories { - workspace_api::validate_repository_key(repository_key).map_err(|error| { - Error::Store(format!( - "repository identity migration rejected {workspace_id}/{repository_key:?}: {error}" - )) - })?; - } - - let tables = { - let mut stmt = conn.prepare( - "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", - )?; - stmt.query_map([], |row| row.get::<_, String>(0))? - .collect::, _>>()? - }; - let mut repository_reference_tables = REPOSITORY_REFERENCE_TABLES - .iter() - .map(|table| (*table).to_string()) - .collect::>(); - for table in &tables { - let quoted = table.replace('"', "\"\""); - let pragma = format!("PRAGMA table_info(\"{quoted}\")"); - let mut stmt = conn.prepare(&pragma)?; - let has_repository_id = stmt - .query_map([], |row| row.get::<_, String>(1))? - .collect::, _>>()? - .iter() - .any(|column| column == "repository_id"); - if !has_repository_id - || table == "repositories" - || table == "legacy_repositories" - || REPOSITORY_REFERENCE_TABLES.contains(&table.as_str()) - { - continue; - } - - let pragma = format!("PRAGMA foreign_key_list(\"{quoted}\")"); - let mut stmt = conn.prepare(&pragma)?; - let references_repository_authority = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - })? - .collect::, _>>()? - .iter() - .any(|(parent, from, to)| { - parent == "repositories" && from == "repository_id" && to == "repository_id" - }); - if references_repository_authority { - repository_reference_tables.push(table.clone()); - } else { - return Err(Error::Store(format!( - "repository identity migration does not recognize repository_id authority in table {table}" - ))); - } - } - for table in &repository_reference_tables { - if !tables.iter().any(|candidate| candidate == table) { - continue; - } - let quoted = table.replace('"', "\"\""); - let sql = format!( - r#"SELECT COUNT(*) - FROM "{quoted}" AS child - LEFT JOIN repositories AS repository - ON repository.workspace_id = child.workspace_id - AND repository.repository_id = child.repository_id - WHERE child.repository_id IS NOT NULL - AND repository.repository_id IS NULL"# - ); - let dangling: i64 = conn.query_row(&sql, [], |row| row.get(0))?; - if dangling != 0 { - return Err(Error::Store(format!( - "repository identity migration found {dangling} dangling same-Workspace reference(s) in {table}" - ))); - } - } - - conn.execute_batch( - r#" - CREATE TEMP TABLE repository_identity_v50 ( - workspace_id TEXT NOT NULL, - old_repository_id TEXT NOT NULL, - new_repository_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, old_repository_id), - UNIQUE (new_repository_id) - ) WITHOUT ROWID; - "#, - )?; - for (workspace_id, old_repository_id) in &legacy_repositories { - conn.execute( - r#"INSERT INTO repository_identity_v50 ( - workspace_id, old_repository_id, new_repository_id - ) VALUES (?1, ?2, ?3)"#, - params![workspace_id, old_repository_id, Uuid::now_v7().to_string()], - )?; - } - - for table in &repository_reference_tables { - if !tables.iter().any(|candidate| candidate == table) { - continue; - } - let quoted = table.replace('"', "\"\""); - let sql = format!( - r#"UPDATE "{quoted}" AS child - SET repository_id = ( - SELECT mapping.new_repository_id - FROM repository_identity_v50 AS mapping - WHERE mapping.workspace_id = child.workspace_id - AND mapping.old_repository_id = child.repository_id - ) - WHERE child.repository_id IS NOT NULL"# - ); - conn.execute(&sql, [])?; - } - - conn.execute_batch( - r#" - CREATE TABLE repositories_v50 ( - workspace_id TEXT NOT NULL, - repository_id TEXT NOT NULL PRIMARY KEY, - repository_key TEXT NOT NULL - CHECK(length(repository_key) BETWEEN 1 AND 64) - CHECK(repository_key NOT GLOB '*[^a-z0-9-]*') - CHECK(substr(repository_key, 1, 1) <> '-') - CHECK(substr(repository_key, -1, 1) <> '-'), - kind TEXT NOT NULL, - provider TEXT, - uri TEXT NOT NULL, - default_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - source_kind TEXT NOT NULL, - source_uri TEXT NOT NULL, - source_revision INTEGER NOT NULL DEFAULT 1, - source_fingerprint TEXT NOT NULL, - observed_status TEXT NOT NULL DEFAULT 'unverified', - observed_at TEXT, - UNIQUE(workspace_id, repository_key), - UNIQUE(workspace_id, repository_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT INTO repositories_v50 ( - workspace_id, repository_id, repository_key, kind, provider, uri, - default_ref, created_at, updated_at, source_kind, source_uri, - source_revision, source_fingerprint, observed_status, observed_at - ) - SELECT repository.workspace_id, - mapping.new_repository_id, - repository.repository_id, - repository.kind, - repository.provider, - repository.uri, - repository.default_ref, - repository.created_at, - repository.updated_at, - repository.source_kind, - repository.source_uri, - repository.source_revision, - repository.source_fingerprint, - repository.observed_status, - repository.observed_at - FROM repositories AS repository - JOIN repository_identity_v50 AS mapping - ON mapping.workspace_id = repository.workspace_id - AND mapping.old_repository_id = repository.repository_id; - DROP TABLE repositories; - ALTER TABLE repositories_v50 RENAME TO repositories; - CREATE INDEX repositories_workspace_provider_idx - ON repositories(workspace_id, provider); - DROP TABLE repository_identity_v50; - "#, - )?; - Ok(()) -} - -fn require_workspace_account_owner(conn: &Connection) -> Result<()> { - let actual_columns = { - let mut statement = conn.prepare("PRAGMA table_info(workspaces)")?; - statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, i64>(5)?, - )) - })? - .collect::>>()? - }; - let expected_columns = vec![ - ("workspace_id".to_string(), "TEXT".to_string(), 0, None, 1), - ("display_name".to_string(), "TEXT".to_string(), 1, None, 0), - ("state".to_string(), "TEXT".to_string(), 1, None, 0), - ("created_at".to_string(), "TEXT".to_string(), 1, None, 0), - ("updated_at".to_string(), "TEXT".to_string(), 1, None, 0), - ( - "owner_account_id".to_string(), - "TEXT".to_string(), - 0, - None, - 0, - ), - ]; - if actual_columns != expected_columns { - return Err(Error::Store(format!( - "Workspace owner migration rejected workspaces column schema drift: expected {expected_columns:?}, found {actual_columns:?}" - ))); - } - let table_sql = conn.query_row( - "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'workspaces'", - [], - |row| row.get::<_, String>(0), - )?; - let expected_table_sql = r#"CREATE TABLE workspaces ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - state TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - , owner_account_id TEXT REFERENCES accounts(account_id) ON DELETE SET NULL)"#; - if normalize_schema_sql(&table_sql) != normalize_schema_sql(expected_table_sql) { - return Err(Error::Store(format!( - "Workspace owner migration rejected workspaces table SQL drift: found {table_sql}" - ))); - } - let owner_foreign_key = conn - .query_row( - "SELECT \"table\", \"to\", on_delete \ - FROM pragma_foreign_key_list('workspaces') \ - WHERE \"from\" = 'owner_account_id'", - [], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }, - ) - .optional()?; - if owner_foreign_key - .as_ref() - .map(|(table, column, on_delete)| (table.as_str(), column.as_str(), on_delete.as_str())) - != Some(("accounts", "account_id", "SET NULL")) - { - return Err(Error::Store( - "Workspace owner migration rejected owner_account_id schema drift; expected nullable accounts(account_id) with ON DELETE SET NULL" - .to_string(), - )); - } - - let workspace_schema_objects = { - let mut statement = conn.prepare( - "SELECT sql FROM sqlite_schema \ - WHERE tbl_name = 'workspaces' \ - AND type IN ('index', 'trigger') \ - AND sql IS NOT NULL \ - ORDER BY type, name", - )?; - statement - .query_map([], |row| row.get::<_, String>(0))? - .collect::>>()? - }; - let invalid_workspace_owners = conn.query_row( - "SELECT COUNT(*) \ - FROM workspaces AS workspace \ - LEFT JOIN accounts AS owner ON owner.account_id = workspace.owner_account_id \ - WHERE workspace.owner_account_id IS NULL \ - OR owner.account_id IS NULL \ - OR owner.kind <> 'user'", - [], - |row| row.get::<_, i64>(0), - )?; - if invalid_workspace_owners != 0 { - let invalid_workspace_ids = { - let mut statement = conn.prepare( - "SELECT workspace.workspace_id \ - FROM workspaces AS workspace \ - LEFT JOIN accounts AS owner ON owner.account_id = workspace.owner_account_id \ - WHERE workspace.owner_account_id IS NULL \ - OR owner.account_id IS NULL \ - OR owner.kind <> 'user' \ - ORDER BY workspace.workspace_id \ - LIMIT 20", - )?; - statement - .query_map([], |row| row.get::<_, String>(0))? - .collect::>>()? - }; - let omitted = invalid_workspace_owners.saturating_sub(invalid_workspace_ids.len() as i64); - let omitted_suffix = if omitted == 0 { - String::new() - } else { - format!("; {omitted} additional Workspace record(s) omitted") - }; - return Err(Error::Store(format!( - "Workspace owner migration requires one explicit User Account owner for every Workspace; invalid Workspace IDs: [{}]{omitted_suffix}", - invalid_workspace_ids.join(", ") - ))); - } - - conn.execute_batch( - r#" - CREATE TABLE workspaces_v48 ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - state TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - owner_account_id TEXT NOT NULL, - FOREIGN KEY (owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT - ); - INSERT INTO workspaces_v48 ( - workspace_id, - display_name, - state, - created_at, - updated_at, - owner_account_id - ) - SELECT - workspace_id, - display_name, - state, - created_at, - updated_at, - owner_account_id - FROM workspaces; - DROP TABLE workspaces; - ALTER TABLE workspaces_v48 RENAME TO workspaces; - "#, - )?; - for sql in workspace_schema_objects { - conn.execute_batch(&sql)?; - } - Ok(()) -} - -fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - ALTER TABLE trusted_runtime_records - ADD COLUMN workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT; - CREATE INDEX idx_trusted_runtime_records_workspace - ON trusted_runtime_records(workspace_id, revoked_at, runtime_id); - - CREATE TABLE workspace_create_operations ( - operation_key TEXT PRIMARY KEY, - request_fingerprint TEXT NOT NULL, - workspace_id TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - "#, - )?; +fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(include_str!("latest_schema.sql"))?; Ok(()) } @@ -7668,12 +5988,9 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result, ) -> Result<()> { let has_assignment_tombstones = table_exists(conn, "ticket_assignment_worker_tombstones")?; - let legacy_tombstone_repairs = legacy_assignment_worker_tombstone_repairs(conn)?; let tombstone_filter = if has_assignment_tombstones { "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_worker_tombstones AS tombstone \ WHERE tombstone.workspace_id = assignment.workspace_id \ @@ -7770,13 +6086,6 @@ fn collect_assignment_worker_reference_diagnostics( let mut worker_diagnostic_count = 0; for row in rows { let (workspace_id, assignment_id, runtime_id, worker_id) = row?; - if legacy_tombstone_repairs.contains(&( - workspace_id.clone(), - runtime_id.clone(), - worker_id.clone(), - )) { - continue; - } diagnostics.push(format!( "ticket_worker_assignments.worker_id: \ {workspace_id}/{assignment_id} -> {runtime_id}/{worker_id}" @@ -7789,59 +6098,6 @@ fn collect_assignment_worker_reference_diagnostics( Ok(()) } -fn legacy_assignment_worker_tombstone_repairs( - conn: &Connection, -) -> Result> { - if current_schema_version(conn)? >= 39 - || table_exists(conn, "ticket_assignment_worker_tombstones")? - || !table_exists(conn, "ticket_worker_assignments")? - || !table_exists(conn, "worker_registry")? - || !column_exists(conn, "ticket_worker_assignments", "worker_id")? - || !column_exists(conn, "worker_registry", "worker_id")? - { - return Ok(std::collections::BTreeSet::new()); - } - - let mut repairs = std::collections::BTreeSet::new(); - let mut statement = conn.prepare( - "SELECT DISTINCT assignment.workspace_id, assignment.runtime_id, assignment.worker_id \ - FROM ticket_worker_assignments AS assignment \ - WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ - WHERE worker.workspace_id = assignment.workspace_id \ - AND worker.runtime_id = assignment.runtime_id \ - AND worker.worker_id = assignment.worker_id)", - )?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - for row in rows { - let (workspace_id, runtime_id, worker_id) = row?; - if WorkerId::parse(&worker_id).is_none() { - continue; - } - let exists_only_outside_workspace: bool = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM worker_registry \ - WHERE worker_id = ?1 AND workspace_id != ?2) \ - AND NOT EXISTS(SELECT 1 FROM worker_registry \ - WHERE worker_id = ?1 AND workspace_id = ?2)", - params![worker_id, workspace_id], - |row| row.get(0), - )?; - if !exists_only_outside_workspace { - // Before v39, supported cleanup and Runtime-placement changes could remove or move a - // Worker without recording an assignment-specific tombstone. A valid, - // non-cross-Workspace Worker identity is sufficient legacy evidence; v39 - // materializes the missing tombstone in the migration transaction. - repairs.insert((workspace_id, runtime_id, worker_id)); - } - } - Ok(repairs) -} - fn collect_reference_diagnostics( conn: &Connection, sql: &str, @@ -7926,511 +6182,6 @@ fn current_schema_version(conn: &Connection) -> Result { .map_err(Error::from) } -fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS workspace_config_trees ( - workspace_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL CHECK (revision >= 0), - tree_digest TEXT NOT NULL, - schema_version INTEGER NOT NULL, - entrypoints_json TEXT NOT NULL, - decodal_version TEXT NOT NULL, - import_policy_version INTEGER NOT NULL, - toolchain_fingerprint TEXT NOT NULL, - projection_digest TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS workspace_config_entries ( - workspace_id TEXT NOT NULL, - path TEXT NOT NULL, - content_type TEXT NOT NULL, - content TEXT NOT NULL, - content_digest TEXT NOT NULL, - PRIMARY KEY (workspace_id, path), - FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_workspace_config_entries_prefix - ON workspace_config_entries(workspace_id, path); - CREATE TABLE IF NOT EXISTS workspace_config_tree_revisions ( - workspace_id TEXT NOT NULL, - revision INTEGER NOT NULL, - tree_digest TEXT NOT NULL, - toolchain_fingerprint TEXT NOT NULL, - projection_digest TEXT NOT NULL, - manifest_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, revision), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - "#, - )?; - Ok(()) -} - -pub(crate) fn persist_workspace_config_schema_bundles(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - ALTER TABLE workspace_config_trees - ADD COLUMN schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}'; - ALTER TABLE workspace_config_tree_revisions - ADD COLUMN schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}'; - "#, - )?; - let bundle = config_source::WorkspaceConfigSchemaBundle::empty(); - let bundle_json = - serde_json::to_string(&bundle).map_err(|error| Error::Store(error.to_string()))?; - let contract = config_source::ToolchainContract::with_schema_bundle( - config_source::DEFAULT_SCHEMA_VERSION, - vec![ - config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT) - .map_err(|error| Error::Store(error.to_string()))?, - ], - config_source::DEFAULT_IMPORT_POLICY_VERSION, - bundle, - ); - conn.execute( - "UPDATE workspace_config_trees - SET schema_bundle_json = ?1, - toolchain_fingerprint = ?2", - params![bundle_json, contract.fingerprint], - )?; - conn.execute( - "UPDATE workspace_config_tree_revisions - SET schema_bundle_json = ?1, - toolchain_fingerprint = ?2", - params![bundle_json, contract.fingerprint], - )?; - Ok(()) -} - -fn create_worker_control_grant_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE worker_control_grants ( - workspace_id TEXT NOT NULL, - grant_id TEXT NOT NULL, - controller_runtime_id TEXT NOT NULL, - controller_worker_id INTEGER NOT NULL, - subject_runtime_id TEXT NOT NULL, - subject_worker_id INTEGER NOT NULL, - relation TEXT NOT NULL, - origin TEXT NOT NULL, - permissions_json TEXT NOT NULL, - operation_id TEXT NOT NULL, - created_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE ( - workspace_id, - controller_runtime_id, - controller_worker_id, - operation_id - ), - FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) - REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) - REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id) - ON DELETE CASCADE - ); - - CREATE INDEX idx_worker_control_grants_controller_active - ON worker_control_grants ( - workspace_id, - controller_runtime_id, - controller_worker_id, - revoked_at, - created_at - ); - - CREATE INDEX idx_worker_control_grants_subject_active - ON worker_control_grants ( - workspace_id, - subject_runtime_id, - subject_worker_id, - revoked_at - ); - "#, - )?; - Ok(()) -} - -fn create_worker_control_delegation_operation_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE worker_control_delegation_operations ( - workspace_id TEXT NOT NULL, - source_controller_runtime_id TEXT NOT NULL, - source_controller_worker_id INTEGER NOT NULL, - source_grant_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - input_fingerprint TEXT NOT NULL, - delegated_grant_id TEXT, - created_at TEXT NOT NULL, - completed_at TEXT, - PRIMARY KEY ( - workspace_id, - source_controller_runtime_id, - source_controller_worker_id, - operation_id - ), - FOREIGN KEY (workspace_id, source_controller_runtime_id, source_controller_worker_id) - REFERENCES worker_registry (workspace_id, runtime_id, runtime_worker_id) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, source_grant_id) - REFERENCES worker_control_grants (workspace_id, grant_id) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, delegated_grant_id) - REFERENCES worker_control_grants (workspace_id, grant_id) - ON DELETE SET NULL - ); - "#, - )?; - Ok(()) -} - -fn add_objective_query_indexes(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE INDEX IF NOT EXISTS objectives_workspace_state_updated - ON objectives(workspace_id, state, updated_at DESC, objective_id); - CREATE INDEX IF NOT EXISTS objectives_workspace_updated - ON objectives(workspace_id, updated_at DESC, objective_id); - CREATE INDEX IF NOT EXISTS objectives_workspace_created - ON objectives(workspace_id, created_at DESC, objective_id); - CREATE INDEX IF NOT EXISTS objectives_workspace_title - ON objectives(workspace_id, title COLLATE NOCASE, objective_id); - CREATE INDEX IF NOT EXISTS objective_ticket_links_workspace_ticket_objective - ON objective_ticket_links(workspace_id, ticket_id, objective_id); - "#, - )?; - Ok(()) -} - -fn collect_legacy_text_worker_bindings( - conn: &Connection, - table: &str, - bindings: &mut std::collections::BTreeSet<(String, String, u64)>, -) -> Result<()> { - if !table_exists(conn, table)? { - return Ok(()); - } - let sql = format!( - "SELECT workspace_id, runtime_id, worker_id FROM {table} WHERE worker_id IS NOT NULL" - ); - let mut statement = conn.prepare(&sql)?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - for row in rows { - let (workspace_id, runtime_id, worker_id) = row?; - let legacy_id = worker_id - .strip_prefix("worker-") - .unwrap_or(&worker_id) - .parse::() - .map_err(|_| { - Error::InvalidInput(format!( - "{table} contains non-legacy Worker id `{worker_id}` before schema v37" - )) - })?; - bindings.insert((workspace_id, runtime_id, legacy_id)); - } - Ok(()) -} - -fn apply_workspace_worker_uuid_identity_migration(conn: &Connection) -> Result<()> { - promote_workspace_worker_uuid_identity(conn).map(|_| ()) -} - -fn promote_workspace_worker_uuid_identity( - conn: &Connection, -) -> Result> { - conn.execute_batch( - r#" - PRAGMA defer_foreign_keys = ON; - CREATE TEMP TABLE worker_identity_v37 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - worker_id TEXT NOT NULL UNIQUE, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id) - ); - "#, - )?; - - let mut legacy_workers = std::collections::BTreeSet::new(); - { - let mut statement = conn.prepare( - "SELECT workspace_id, runtime_id, runtime_worker_id FROM worker_registry \ - ORDER BY workspace_id, runtime_id, runtime_worker_id", - )?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - )) - })?; - for row in rows { - let (workspace_id, runtime_id, runtime_worker_id) = row?; - legacy_workers.insert(( - workspace_id, - runtime_id, - u64::try_from(runtime_worker_id).map_err(|_| { - Error::InvalidInput(format!( - "legacy Runtime Worker id {runtime_worker_id} is negative" - )) - })?, - )); - } - } - // v21/v22 removed the legacy per-Worker credential and Ticket notification - // tables. Every Worker-reference table that still exists at the v36 boundary - // participates in this map, including retention-only Workers no longer present - // in worker_registry. - for table in [ - "ticket_worker_assignments", - "ticket_current_worker_assignments", - "ticket_assignment_operations", - "worker_removal_operations", - "worker_session_archives", - "worker_diagnostics_archives", - "worker_tombstones", - "worker_orphan_diagnostics", - ] { - collect_legacy_text_worker_bindings(conn, table, &mut legacy_workers)?; - } - - let mut mappings = Vec::with_capacity(legacy_workers.len()); - for (workspace_id, runtime_id, runtime_worker_id) in &legacy_workers { - let worker_id = WorkerId::from_legacy_binding(workspace_id, runtime_id, *runtime_worker_id); - conn.execute( - "INSERT INTO worker_identity_v37(\ - workspace_id, runtime_id, runtime_worker_id, worker_id\ - ) VALUES (?1, ?2, ?3, ?4)", - params![ - workspace_id, - runtime_id, - runtime_worker_id, - worker_id.to_string() - ], - )?; - mappings.push(LegacyWorkerIdentityMapping { - workspace_id: workspace_id.clone(), - runtime_id: runtime_id.clone(), - legacy_worker_id: *runtime_worker_id, - worker_id, - }); - } - - conn.execute_batch( - r#" - CREATE TABLE worker_registry_v37 ( - workspace_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - profile TEXT, - retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), - transcript_ref TEXT, - session_ref TEXT, - summary_ref TEXT, - diagnostics_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT INTO worker_registry_v37( - workspace_id, worker_id, runtime_id, display_name, profile, - retention_state, transcript_ref, session_ref, summary_ref, - diagnostics_ref, created_at, updated_at - ) - SELECT r.workspace_id, m.worker_id, r.runtime_id, r.display_name, r.profile, - r.retention_state, r.transcript_ref, r.session_ref, r.summary_ref, - r.diagnostics_ref, r.created_at, r.updated_at - FROM worker_registry r - JOIN worker_identity_v37 m - ON m.workspace_id = r.workspace_id - AND m.runtime_id = r.runtime_id - AND m.runtime_worker_id = r.runtime_worker_id; - - CREATE TABLE worker_workdir_links_v37 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - role TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, role), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry_v37(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) - REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT INTO worker_workdir_links_v37( - workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at - ) - SELECT l.workspace_id, l.runtime_id, m.worker_id, l.workdir_id, l.role, l.linked_at, l.unlinked_at - FROM worker_workdir_links l - JOIN worker_identity_v37 m - ON m.workspace_id = l.workspace_id - AND m.runtime_id = l.runtime_id - AND m.runtime_worker_id = l.runtime_worker_id; - - CREATE TABLE worker_control_grants_v37 ( - grant_id TEXT NOT NULL, - workspace_id TEXT NOT NULL, - controller_runtime_id TEXT NOT NULL, - controller_worker_id TEXT NOT NULL, - subject_runtime_id TEXT NOT NULL, - subject_worker_id TEXT NOT NULL, - relation TEXT NOT NULL, - origin TEXT NOT NULL, - permissions_json TEXT NOT NULL, - operation_id TEXT NOT NULL, - created_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, controller_worker_id, operation_id), - FOREIGN KEY (workspace_id, controller_worker_id) - REFERENCES worker_registry_v37(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, subject_worker_id) - REFERENCES worker_registry_v37(workspace_id, worker_id) ON DELETE CASCADE - ); - INSERT INTO worker_control_grants_v37( - grant_id, workspace_id, - controller_runtime_id, controller_worker_id, - subject_runtime_id, subject_worker_id, - relation, origin, permissions_json, operation_id, - created_at, revoked_at - ) - SELECT g.grant_id, g.workspace_id, - g.controller_runtime_id, controller.worker_id, - g.subject_runtime_id, subject.worker_id, - g.relation, g.origin, g.permissions_json, g.operation_id, - g.created_at, g.revoked_at - FROM worker_control_grants g - JOIN worker_identity_v37 controller - ON controller.workspace_id = g.workspace_id - AND controller.runtime_id = g.controller_runtime_id - AND controller.runtime_worker_id = g.controller_worker_id - JOIN worker_identity_v37 subject - ON subject.workspace_id = g.workspace_id - AND subject.runtime_id = g.subject_runtime_id - AND subject.runtime_worker_id = g.subject_worker_id; - - UPDATE ticket_worker_assignments - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = ticket_worker_assignments.workspace_id - AND m.runtime_id = ticket_worker_assignments.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = ticket_worker_assignments.worker_id - ); - UPDATE ticket_current_worker_assignments - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = ticket_current_worker_assignments.workspace_id - AND m.runtime_id = ticket_current_worker_assignments.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = ticket_current_worker_assignments.worker_id - ); - UPDATE ticket_assignment_operations - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = ticket_assignment_operations.workspace_id - AND m.runtime_id = ticket_assignment_operations.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = ticket_assignment_operations.worker_id - ) - WHERE worker_id IS NOT NULL; - - UPDATE worker_removal_operations - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = worker_removal_operations.workspace_id - AND m.runtime_id = worker_removal_operations.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = worker_removal_operations.worker_id - ); - UPDATE worker_session_archives - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = worker_session_archives.workspace_id - AND m.runtime_id = worker_session_archives.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = worker_session_archives.worker_id - ); - UPDATE worker_diagnostics_archives - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = worker_diagnostics_archives.workspace_id - AND m.runtime_id = worker_diagnostics_archives.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = worker_diagnostics_archives.worker_id - ); - UPDATE worker_tombstones - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = worker_tombstones.workspace_id - AND m.runtime_id = worker_tombstones.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = worker_tombstones.worker_id - ); - UPDATE worker_orphan_diagnostics - SET worker_id = ( - SELECT m.worker_id FROM worker_identity_v37 m - WHERE m.workspace_id = worker_orphan_diagnostics.workspace_id - AND m.runtime_id = worker_orphan_diagnostics.runtime_id - AND CAST(m.runtime_worker_id AS TEXT) = worker_orphan_diagnostics.worker_id - ); - - DROP TABLE worker_control_grants; - DROP TABLE worker_workdir_links; - DROP TABLE worker_registry; - ALTER TABLE worker_registry_v37 RENAME TO worker_registry; - ALTER TABLE worker_workdir_links_v37 RENAME TO worker_workdir_links; - ALTER TABLE worker_control_grants_v37 RENAME TO worker_control_grants; - CREATE INDEX worker_registry_runtime - ON worker_registry(workspace_id, runtime_id, worker_id); - CREATE INDEX worker_workdir_links_workdir - ON worker_workdir_links(workspace_id, workdir_id); - CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique - ON worker_workdir_links(workspace_id, worker_id) - WHERE unlinked_at IS NULL; - CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique - ON worker_workdir_links(workspace_id, workdir_id) - WHERE unlinked_at IS NULL; - CREATE INDEX worker_control_grants_controller - ON worker_control_grants( - workspace_id, controller_worker_id, revoked_at - ); - CREATE INDEX worker_control_grants_subject - ON worker_control_grants( - workspace_id, subject_worker_id, revoked_at - ); - CREATE TABLE worker_create_reservations ( - workspace_id TEXT NOT NULL, - allocation_key TEXT NOT NULL, - worker_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - create_fingerprint TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('reserved', 'created')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, allocation_key), - UNIQUE (workspace_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE INDEX worker_create_reservations_worker - ON worker_create_reservations(workspace_id, worker_id); - DROP TABLE worker_identity_v37; - "#, - )?; - Ok(mappings) -} - fn allocate_resource_key( conn: &Connection, workspace_id: &str, @@ -8482,1913 +6233,53 @@ fn allocate_resource_key( Ok(resource_key) } -fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS workspace_resource_human_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), - human_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, human_key) - ); - CREATE TABLE IF NOT EXISTS workspace_resource_human_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) - ); - - INSERT OR IGNORE INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at - ) - SELECT workspace_id, - 'objective', - objective_id, - ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC), - 'O-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC), - created_at - FROM objectives; - INSERT OR IGNORE INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at - ) - SELECT workspace_id, - 'worker', - worker_id, - ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC), - 'W-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC), - created_at - FROM worker_registry; - - INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) - SELECT workspace_id, resource_kind, MAX(sequence) + 1 - FROM workspace_resource_human_keys - GROUP BY workspace_id, resource_kind - ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET - next_sequence = MAX(next_sequence, excluded.next_sequence); - "#, - )?; - if table_exists(conn, "typed_tickets")? { - conn.execute_batch( - r#" - INSERT OR IGNORE INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at - ) - SELECT workspace_id, - 'ticket', - ticket_id, - ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC), - 'T-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC), - created_at - FROM typed_tickets; - INSERT INTO workspace_resource_human_key_counters ( - workspace_id, resource_kind, next_sequence - ) - SELECT workspace_id, 'ticket', MAX(sequence) + 1 - FROM workspace_resource_human_keys - WHERE resource_kind = 'ticket' - GROUP BY workspace_id - ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET - next_sequence = MAX(next_sequence, excluded.next_sequence); - "#, - )?; - } - Ok(()) -} - -fn create_workspace_memory_settings_authority(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE workspace_memory_settings ( - workspace_id TEXT PRIMARY KEY NOT NULL, - settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1), - language TEXT NOT NULL CHECK(length(trim(language)) > 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - - INSERT INTO workspace_memory_settings ( - workspace_id, - settings_revision, - language, - created_at, - updated_at - ) - SELECT workspace_id, 1, 'English', created_at, updated_at - FROM workspaces; - - ALTER TABLE worker_create_reservations - ADD COLUMN request_fingerprint TEXT; - ALTER TABLE worker_create_reservations - ADD COLUMN memory_settings_revision INTEGER; - ALTER TABLE worker_create_reservations - ADD COLUMN memory_language TEXT; - - UPDATE worker_create_reservations - SET request_fingerprint = create_fingerprint; - "#, - )?; - Ok(()) -} - -fn repairable_legacy_current_ticket_assignment_count(conn: &Connection) -> Result { - let (stale_current, unclassified_current): (i64, i64) = conn.query_row( - r#" - SELECT - COALESCE(SUM(CASE - WHEN ticket.ticket_id IS NULL OR worker.worker_id IS NULL THEN 1 - ELSE 0 - END), 0), - COALESCE(SUM(CASE - WHEN (ticket.ticket_id IS NULL AND ticket_tombstone.ticket_id IS NULL) - OR (worker.worker_id IS NULL AND worker_tombstone.worker_id IS NULL) - THEN 1 - ELSE 0 - END), 0) - FROM ticket_current_worker_assignments AS current - LEFT JOIN typed_tickets AS ticket - ON ticket.workspace_id = current.workspace_id - AND ticket.ticket_id = current.ticket_id - LEFT JOIN worker_registry AS worker - ON worker.workspace_id = current.workspace_id - AND worker.runtime_id = current.runtime_id - AND worker.worker_id = current.worker_id - LEFT JOIN ticket_assignment_ticket_tombstones AS ticket_tombstone - ON ticket_tombstone.workspace_id = current.workspace_id - AND ticket_tombstone.ticket_id = current.ticket_id - LEFT JOIN ticket_assignment_worker_tombstones AS worker_tombstone - ON worker_tombstone.workspace_id = current.workspace_id - AND worker_tombstone.runtime_id = current.runtime_id - AND worker_tombstone.worker_id = current.worker_id - "#, +fn verify_baseline_history(conn: &Connection) -> Result<()> { + let rows = conn.query_row( + "SELECT COUNT(*), COALESCE(MAX(version), 0), COALESCE(MAX(name), '') \ + FROM __yoi_schema_migrations", [], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }, )?; - if unclassified_current != 0 { + let baseline = &MIGRATIONS[0]; + if rows != (1, baseline.version, baseline.name.to_string()) { return Err(Error::Store(format!( - "migration 43 cannot repair {unclassified_current} legacy current Ticket assignment(s) whose missing Ticket or Worker has no matching tombstone" + "database migration history is not the canonical schema baseline: expected only version {} ({:?}), found {} row(s) ending at version {} ({:?})", + baseline.version, baseline.name, rows.0, rows.1, rows.2 ))); } - Ok(stale_current) -} - -fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> { - let stale_current = repairable_legacy_current_ticket_assignment_count(conn)?; - if stale_current > 0 { - let cleared = conn.execute( - r#" - DELETE FROM ticket_current_worker_assignments AS current - WHERE NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = current.workspace_id - AND ticket.ticket_id = current.ticket_id - ) - OR NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = current.workspace_id - AND worker.runtime_id = current.runtime_id - AND worker.worker_id = current.worker_id - ) - "#, - [], - )?; - if i64::try_from(cleared).ok() != Some(stale_current) { - return Err(Error::Store(format!( - "migration 43 classified {stale_current} stale current Ticket assignment(s) but cleared {cleared}" - ))); - } - } - - conn.execute_batch( - r#" - DROP INDEX IF EXISTS idx_ticket_worker_assignments_ticket; - DROP INDEX IF EXISTS idx_ticket_worker_assignments_worker; - DROP INDEX IF EXISTS idx_ticket_current_worker_assignments_worker; - DROP INDEX IF EXISTS idx_ticket_worker_assignment_events_ticket; - DROP INDEX IF EXISTS idx_ticket_assignment_operations_ticket; - DROP TRIGGER IF EXISTS ticket_assignment_ticket_parent_tombstone; - DROP TRIGGER IF EXISTS ticket_assignment_worker_parent_tombstone_delete; - DROP TRIGGER IF EXISTS ticket_assignment_worker_parent_tombstone_move; - DROP TRIGGER IF EXISTS ticket_worker_assignments_validate_insert; - DROP TRIGGER IF EXISTS ticket_worker_assignments_validate_update; - DROP TRIGGER IF EXISTS ticket_worker_assignment_events_validate_insert; - DROP TRIGGER IF EXISTS ticket_assignment_operations_validate_insert; - - ALTER TABLE ticket_worker_assignments RENAME TO ticket_worker_assignments_v43; - ALTER TABLE ticket_current_worker_assignments RENAME TO ticket_current_worker_assignments_v43; - ALTER TABLE ticket_worker_assignment_events RENAME TO ticket_worker_assignment_events_v43; - ALTER TABLE ticket_assignment_operations RENAME TO ticket_assignment_operations_v43; - - CREATE TABLE ticket_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', '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 idx_ticket_worker_assignments_ticket - ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); - CREATE INDEX idx_ticket_worker_assignments_principal - ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); - - CREATE TABLE ticket_current_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', '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, - FOREIGN KEY (workspace_id, runtime_id, worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) - ON DELETE RESTRICT, - 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 UNIQUE INDEX ticket_current_singleton_role_idx - ON ticket_current_worker_assignments(workspace_id, ticket_id, role) - WHERE role IN ('orchestrator', 'coder'); - CREATE UNIQUE INDEX ticket_current_worker_role_idx - ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id) - WHERE principal_kind = 'worker'; - CREATE INDEX ticket_current_principal_idx - ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); - - CREATE TABLE ticket_worker_assignment_events ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', '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 INDEX idx_ticket_worker_assignment_events_ticket - ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); - - CREATE TABLE ticket_assignment_operations ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'coder' - CHECK(role IN ('orchestrator', 'coder', '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, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - CREATE INDEX idx_ticket_assignment_operations_ticket - ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); - - INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, role, principal_kind, - runtime_id, worker_id, assigned_by, assigned_at - ) - SELECT workspace_id, ticket_id, assignment_id, 'coder', 'worker', - runtime_id, worker_id, assigned_by, assigned_at - FROM ticket_worker_assignments_v43; - - INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, role, assignment_id, principal_kind, - runtime_id, worker_id, updated_at - ) - SELECT workspace_id, ticket_id, 'coder', assignment_id, 'worker', - runtime_id, worker_id, updated_at - FROM ticket_current_worker_assignments_v43; - - INSERT INTO ticket_worker_assignment_events ( - workspace_id, ticket_id, role, event_id, action, assignment_id, - previous_assignment_id, actor, created_at - ) - SELECT workspace_id, ticket_id, 'coder', event_id, action, assignment_id, - previous_assignment_id, actor, created_at - FROM ticket_worker_assignment_events_v43; - - INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, role, principal_kind, - runtime_id, worker_id, assignment_id, expected_assignment_id, - created_at, request_fingerprint - ) - SELECT workspace_id, operation_id, action, ticket_id, 'coder', 'worker', - runtime_id, worker_id, assignment_id, expected_assignment_id, - created_at, request_fingerprint - FROM ticket_assignment_operations_v43; - - DROP TABLE ticket_current_worker_assignments_v43; - DROP TABLE ticket_worker_assignment_events_v43; - DROP TABLE ticket_assignment_operations_v43; - DROP TABLE ticket_worker_assignments_v43; - - CREATE TRIGGER ticket_assignment_ticket_parent_tombstone - BEFORE DELETE ON typed_tickets - WHEN EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.ticket_id = OLD.ticket_id - ) - BEGIN - INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( - workspace_id, ticket_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); - END; - - CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete - BEFORE DELETE ON worker_registry - WHEN EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.principal_kind = 'worker' - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id - ) - BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); - END; - - CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move - BEFORE UPDATE OF runtime_id ON worker_registry - WHEN OLD.runtime_id != NEW.runtime_id - AND EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.principal_kind = 'worker' - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id - ) - BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); - END; - - CREATE TRIGGER ticket_worker_assignments_validate_insert - BEFORE INSERT ON ticket_worker_assignments - WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id - ) - OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = NEW.workspace_id - AND worker.runtime_id = NEW.runtime_id - AND worker.worker_id = NEW.worker_id - )) - OR (NEW.principal_kind = 'user' AND NOT EXISTS ( - SELECT 1 FROM accounts - WHERE account_id = NEW.principal_id AND kind = 'user' - )) - OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') - BEGIN - SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); - END; - - CREATE TRIGGER ticket_worker_assignments_validate_update - BEFORE UPDATE OF workspace_id, ticket_id, principal_kind, principal_id, runtime_id, worker_id - ON ticket_worker_assignments - WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id - ) - OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = NEW.workspace_id - AND worker.runtime_id = NEW.runtime_id - AND worker.worker_id = NEW.worker_id - )) - OR (NEW.principal_kind = 'user' AND NOT EXISTS ( - SELECT 1 FROM accounts - WHERE account_id = NEW.principal_id AND kind = 'user' - )) - OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') - BEGIN - SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); - END; - - CREATE TRIGGER ticket_worker_assignment_events_validate_insert - BEFORE INSERT ON ticket_worker_assignment_events - WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = NEW.workspace_id - AND assignment.ticket_id = NEW.ticket_id - AND assignment.role = NEW.role - AND assignment.assignment_id = NEW.assignment_id - )) - OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = NEW.workspace_id - AND assignment.ticket_id = NEW.ticket_id - AND assignment.role = NEW.role - AND assignment.assignment_id = NEW.previous_assignment_id - )) - BEGIN - SELECT RAISE(ABORT, 'Ticket assignment event must reference the same Ticket role'); - END; - - CREATE TRIGGER ticket_assignment_operations_validate_insert - BEFORE INSERT ON ticket_assignment_operations - WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id - ) - BEGIN - SELECT RAISE(ABORT, 'Ticket assignment operation must reference a Ticket in the same Workspace'); - END; - - CREATE TRIGGER ticket_assignment_user_principal_insert - BEFORE INSERT ON ticket_current_worker_assignments - WHEN NEW.principal_kind = 'user' - BEGIN - SELECT CASE WHEN NOT EXISTS ( - SELECT 1 FROM accounts - WHERE account_id = NEW.principal_id AND kind = 'user' - ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; - END; - - CREATE TRIGGER ticket_assignment_user_principal_update - BEFORE UPDATE ON ticket_current_worker_assignments - WHEN NEW.principal_kind = 'user' - BEGIN - SELECT CASE WHEN NOT EXISTS ( - SELECT 1 FROM accounts - WHERE account_id = NEW.principal_id AND kind = 'user' - ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; - END; - - CREATE TRIGGER ticket_assignment_workspace_agent_insert - BEFORE INSERT ON ticket_current_worker_assignments - WHEN NEW.principal_kind = 'workspace_agent' - BEGIN - SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' - THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; - END; - - CREATE TRIGGER ticket_assignment_workspace_agent_update - BEFORE UPDATE ON ticket_current_worker_assignments - WHEN NEW.principal_kind = 'workspace_agent' - BEGIN - SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' - THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; - END; - "#, - )?; - Ok(()) -} -fn verify_workspace_resource_key_schema(conn: &Connection) -> Result<()> { - ticket::migrate_sqlite_ticket_resource_key_schema_in_transaction(conn).map_err(|error| { - Error::Store(format!( - "migration 41 Ticket resource-key schema failed: {error}" - )) - })?; - for legacy_table in [ - "workspace_resource_human_keys", - "workspace_resource_human_key_counters", - ] { - if table_exists(conn, legacy_table)? { - return Err(Error::Store(format!( - "migration 41 left legacy table `{legacy_table}`" - ))); - } - } - if !table_exists(conn, "workspace_resource_keys")? - || !column_exists(conn, "workspace_resource_keys", "resource_key")? - || column_exists(conn, "workspace_resource_keys", "human_key")? - || !table_exists(conn, "workspace_resource_key_counters")? - { - return Err(Error::Store( - "migration 41 did not materialize the Workspace resource key schema".to_string(), - )); - } - let index_exists = conn - .query_row( - "SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = 'idx_workspace_resource_keys_reverse'", - [], - |_| Ok(()), - ) - .optional()? - .is_some(); - if !index_exists { - return Err(Error::Store( - "migration 41 did not create the Workspace resource key reverse index".to_string(), - )); - } - Ok(()) -} - -fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> { - let mut statement = - conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - let mut permission_updates = Vec::new(); - for row in rows { - let (workspace_id, grant_id, permissions_json) = row?; - let mut permissions: Vec = - serde_json::from_str(&permissions_json).map_err(|error| { - Error::Store(format!( - "failed to decode Worker control grant `{grant_id}` permissions during delegation removal: {error}" - )) - })?; - let previous_len = permissions.len(); - permissions - .retain(|permission| !matches!(permission.as_str(), "share" | "transfer" | "revoke")); - if permissions.len() != previous_len { - permission_updates.push(( - workspace_id, - grant_id, - serde_json::to_string(&permissions).map_err(|error| { - Error::Store(format!( - "failed to encode Worker control grant permissions during delegation removal: {error}" - )) - })?, - )); - } - } - drop(statement); - - for (workspace_id, grant_id, permissions_json) in permission_updates { - conn.execute( - "UPDATE worker_control_grants SET permissions_json = ?3 WHERE workspace_id = ?1 AND grant_id = ?2", - params![workspace_id, grant_id, permissions_json], - )?; - } - conn.execute( - r#"UPDATE worker_control_grants - SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - WHERE relation IN ('shared', 'transferred')"#, - [], - )?; - conn.execute_batch("DROP TABLE IF EXISTS worker_control_delegation_operations;")?; - Ok(()) -} - -fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS worker_mutation_source_proof_jtis ( - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (runtime_id, jti) - ); - CREATE INDEX IF NOT EXISTS idx_worker_mutation_source_proof_jtis_expiry - ON worker_mutation_source_proof_jtis(expires_at); - "#, - )?; - Ok(()) -} - -pub(crate) fn materialize_main_config_entrypoint(conn: &Connection) -> Result<()> { - let mut statement = - conn.prepare("SELECT workspace_id, created_at FROM workspaces ORDER BY workspace_id")?; - let workspaces = statement - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? - .collect::, _>>()?; - drop(statement); - - for (workspace_id, created_at) in workspaces { - let existing = crate::config_source::load_state(conn, &workspace_id)?; - let state = match existing { - None => crate::config_source::initial_state()?, - Some(existing) => { - let main = - config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT) - .map_err(|error| Error::Store(error.to_string()))?; - let snapshot = if existing.snapshot.entries.contains_key(&main) { - existing.snapshot - } else { - existing - .snapshot - .apply(&[config_source::ConfigTreeChange::Create { - path: main.clone(), - content_type: config_source::ConfigContentType::Decodal, - content: crate::config_source::DEFAULT_MAIN_CONFIG_SOURCE.to_string(), - }]) - .map_err(|error| Error::Store(error.to_string()))? - }; - let contract = config_source::ToolchainContract::new( - config_source::DEFAULT_SCHEMA_VERSION, - vec![main], - config_source::DEFAULT_IMPORT_POLICY_VERSION, - ); - let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone()) - .evaluate_contract(&contract) - .map_err(|diagnostics| { - Error::Store(format!( - "cannot materialize main.dcdl for Workspace {workspace_id}: {}", - serde_json::to_string(&diagnostics) - .unwrap_or_else(|_| "config evaluation failed".to_string()) - )) - })?; - crate::config_source::WorkspaceConfigState { - snapshot, - contract, - projection_digest: evaluation.projection_digest, - } - } - }; - conn.execute( - "DELETE FROM workspace_config_tree_revisions WHERE workspace_id = ?1", - [&workspace_id], - )?; - conn.execute( - "DELETE FROM workspace_config_entries WHERE workspace_id = ?1", - [&workspace_id], - )?; - crate::config_source::insert_materialized_state(conn, &workspace_id, &state, &created_at)?; - } - Ok(()) -} - -fn enforce_workspace_resource_foreign_keys(conn: &Connection) -> Result<()> { - let schema = r#" -CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_registry_workspace_runtime_worker - ON worker_registry(workspace_id, runtime_id, worker_id); - -CREATE TABLE objectives_v39 ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO objectives_v39 SELECT * FROM objectives; - -CREATE TABLE objective_events_v39 ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - event_id TEXT NOT NULL, - kind TEXT NOT NULL, - body_md TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, event_id), - FOREIGN KEY (workspace_id, objective_id) - REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE -); -INSERT INTO objective_events_v39 -SELECT workspace_id, objective_id, event_id, kind, body_md, created_at FROM objective_events; - -CREATE TABLE objective_resources_v39 ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - resource_path TEXT NOT NULL, - body TEXT NOT NULL, - media_type TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id, resource_path), - FOREIGN KEY (workspace_id, objective_id) - REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE -); -INSERT INTO objective_resources_v39 -SELECT workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at -FROM objective_resources; - -CREATE TABLE typed_tickets_v39 ( - 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, - repository_id TEXT, - ref_selector TEXT, - PRIMARY KEY (workspace_id, ticket_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT -); -INSERT INTO typed_tickets_v39 SELECT * FROM typed_tickets; - -CREATE TABLE typed_ticket_relations_v39 ( - 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, - FOREIGN KEY (workspace_id, target) - REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE -); -INSERT INTO typed_ticket_relations_v39 SELECT * FROM typed_ticket_relations; - -CREATE TABLE objective_ticket_links_v39 ( - workspace_id TEXT NOT NULL, - objective_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - kind TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), - FOREIGN KEY (workspace_id, objective_id) - REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, ticket_id) - REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE -); -INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; - -CREATE TABLE ticket_assignment_ticket_tombstones ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - deleted_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); - -CREATE TABLE ticket_assignment_worker_tombstones ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - deleted_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); - -CREATE TABLE ticket_worker_assignments_v39 ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - 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, assignment_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments; -INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at -) -SELECT DISTINCT - assignment.workspace_id, - assignment.runtime_id, - assignment.worker_id, - CURRENT_TIMESTAMP -FROM ticket_worker_assignments_v39 AS assignment -WHERE NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = assignment.workspace_id - AND worker.runtime_id = assignment.runtime_id - AND worker.worker_id = assignment.worker_id -); - -CREATE TABLE ticket_worker_assignment_events_v39 ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - 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, - PRIMARY KEY (workspace_id, event_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO ticket_worker_assignment_events_v39 SELECT * FROM ticket_worker_assignment_events; - -CREATE TABLE ticket_current_worker_assignments_v39 ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id), - UNIQUE (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, ticket_id, assignment_id, runtime_id, worker_id) - REFERENCES ticket_worker_assignments( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id - ) ON DELETE CASCADE -); -INSERT INTO ticket_current_worker_assignments_v39 SELECT * FROM ticket_current_worker_assignments; - -CREATE TABLE ticket_assignment_operations_v39 ( - 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, - runtime_id TEXT, - worker_id TEXT, - assignment_id TEXT, - expected_assignment_id TEXT, - created_at TEXT NOT NULL, - request_fingerprint TEXT, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO ticket_assignment_operations_v39 SELECT * FROM ticket_assignment_operations; - -CREATE TABLE artifacts_v39 ( - workspace_id TEXT NOT NULL, - artifact_id TEXT NOT NULL, - kind TEXT NOT NULL, - uri TEXT NOT NULL, - media_type TEXT, - sha256 TEXT, - size_bytes INTEGER, - summary TEXT, - created_at TEXT NOT NULL, - created_by_kind TEXT NOT NULL, - created_by_key TEXT NOT NULL, - created_by_display TEXT NOT NULL, - created_by_source_kind TEXT, - created_by_source_key TEXT, - ticket_id TEXT, - objective_id TEXT, - event_id TEXT, - worker_ref_kind TEXT, - worker_ref_key TEXT, - worker_display TEXT, - repository_id TEXT, - source_kind TEXT, - source_revision TEXT, - PRIMARY KEY (workspace_id, artifact_id), - CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, ticket_id) - REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, objective_id) - REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT -); -INSERT INTO artifacts_v39 SELECT * FROM artifacts; - -CREATE TABLE workspace_resource_human_key_counters_v39 ( - 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), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO workspace_resource_human_key_counters_v39 SELECT * FROM workspace_resource_human_key_counters; - -CREATE TABLE workspace_resource_human_keys_v39 ( - 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), - human_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, human_key), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -INSERT INTO workspace_resource_human_keys_v39 SELECT * FROM workspace_resource_human_keys; - -DROP TABLE ticket_current_worker_assignments; -DROP TABLE ticket_worker_assignment_events; -DROP TABLE ticket_assignment_operations; -DROP TABLE objective_ticket_links; -DROP TABLE objective_events; -DROP TABLE objective_resources; -DROP TABLE typed_ticket_relations; - -ALTER TABLE ticket_worker_assignments RENAME TO ticket_worker_assignments_v38; -ALTER TABLE typed_tickets RENAME TO typed_tickets_v38; -ALTER TABLE objectives RENAME TO objectives_v38; - -ALTER TABLE objectives_v39 RENAME TO objectives; -ALTER TABLE typed_tickets_v39 RENAME TO typed_tickets; -ALTER TABLE ticket_worker_assignments_v39 RENAME TO ticket_worker_assignments; -ALTER TABLE objective_events_v39 RENAME TO objective_events; -ALTER TABLE objective_resources_v39 RENAME TO objective_resources; -ALTER TABLE typed_ticket_relations_v39 RENAME TO typed_ticket_relations; -ALTER TABLE objective_ticket_links_v39 RENAME TO objective_ticket_links; -ALTER TABLE ticket_worker_assignment_events_v39 RENAME TO ticket_worker_assignment_events; -ALTER TABLE ticket_current_worker_assignments_v39 RENAME TO ticket_current_worker_assignments; -ALTER TABLE ticket_assignment_operations_v39 RENAME TO ticket_assignment_operations; - -DROP TABLE ticket_worker_assignments_v38; -DROP TABLE typed_tickets_v38; -DROP TABLE objectives_v38; - -DROP TABLE artifacts; -ALTER TABLE artifacts_v39 RENAME TO artifacts; -DROP TABLE workspace_resource_human_keys; -DROP TABLE workspace_resource_human_key_counters; -ALTER TABLE workspace_resource_human_key_counters_v39 RENAME TO workspace_resource_human_key_counters; -ALTER TABLE workspace_resource_human_keys_v39 RENAME TO workspace_resource_human_keys; - -CREATE INDEX IF NOT EXISTS idx_objectives_workspace_updated - ON objectives(workspace_id, updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_objective_events_workspace_created - ON objective_events(workspace_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_objective_resources_workspace_objective - ON objective_resources(workspace_id, objective_id); -CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_objective - ON objective_ticket_links(workspace_id, objective_id); -CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_ticket - ON objective_ticket_links(workspace_id, ticket_id); -CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_state_updated - ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); -CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_updated - ON typed_tickets(workspace_id, updated_at DESC, ticket_id); -CREATE INDEX IF NOT EXISTS idx_typed_ticket_relations_workspace_target - ON typed_ticket_relations(workspace_id, target, at DESC); -CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket - ON ticket_worker_assignments(workspace_id, ticket_id, assigned_at DESC); -CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_worker - ON ticket_worker_assignments(workspace_id, runtime_id, worker_id, assigned_at DESC); -CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket - ON ticket_worker_assignment_events(workspace_id, ticket_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket - ON ticket_assignment_operations(workspace_id, ticket_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_artifacts_workspace_created - ON artifacts(workspace_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse - ON workspace_resource_human_keys(workspace_id, resource_kind, human_key); -"#; - for statement in schema - .split(';') - .map(str::trim) - .filter(|sql| !sql.is_empty()) - { - conn.execute_batch(statement).map_err(|error| { - Error::Store(format!( - "Workspace resource FK migration statement failed: {statement}: {error}" - )) - })?; - } - // Assignment rows and events are historical evidence and intentionally survive Ticket or - // Worker retention deletion, so parent FKs would impose the wrong delete semantics. Parent - // delete/move triggers record an exact tombstone before authority disappears; insertion - // triggers require every new assignment to resolve both authorities in the same Workspace, - // and event references resolve a committed assignment for the same Ticket. Operation - // assignment/Worker ids remain unconstrained because reservations are persisted before - // assignment/Worker creation and expected ids may intentionally be stale. - conn.execute_batch( - r#" -CREATE TRIGGER ticket_assignment_ticket_parent_tombstone -BEFORE DELETE ON typed_tickets -WHEN EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.ticket_id = OLD.ticket_id - ) - OR EXISTS ( - SELECT 1 FROM ticket_assignment_operations AS operation - WHERE operation.workspace_id = OLD.workspace_id - AND operation.ticket_id = OLD.ticket_id - ) -BEGIN - INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( - workspace_id, ticket_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); -END; - -CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete -BEFORE DELETE ON worker_registry -WHEN EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id -) -BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); -END; - -CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move -BEFORE UPDATE OF runtime_id ON worker_registry -WHEN OLD.runtime_id != NEW.runtime_id - AND EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = OLD.workspace_id - AND assignment.runtime_id = OLD.runtime_id - AND assignment.worker_id = OLD.worker_id -) -BEGIN - INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( - workspace_id, runtime_id, worker_id, deleted_at - ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); -END; - -CREATE TRIGGER ticket_worker_assignments_validate_insert -BEFORE INSERT ON ticket_worker_assignments -WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id - ) - OR NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = NEW.workspace_id - AND worker.runtime_id = NEW.runtime_id - AND worker.worker_id = NEW.worker_id - ) -BEGIN - SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace'); -END; - -CREATE TRIGGER ticket_worker_assignments_validate_update -BEFORE UPDATE OF workspace_id, ticket_id, runtime_id, worker_id ON ticket_worker_assignments -WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id - ) - OR NOT EXISTS ( - SELECT 1 FROM worker_registry AS worker - WHERE worker.workspace_id = NEW.workspace_id - AND worker.runtime_id = NEW.runtime_id - AND worker.worker_id = NEW.worker_id - ) -BEGIN - SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace'); -END; - -CREATE TRIGGER ticket_worker_assignment_events_validate_insert -BEFORE INSERT ON ticket_worker_assignment_events -WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = NEW.workspace_id - AND assignment.ticket_id = NEW.ticket_id - AND assignment.assignment_id = NEW.assignment_id - )) - OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM ticket_worker_assignments AS assignment - WHERE assignment.workspace_id = NEW.workspace_id - AND assignment.ticket_id = NEW.ticket_id - AND assignment.assignment_id = NEW.previous_assignment_id - )) -BEGIN - SELECT RAISE(ABORT, 'ticket_worker_assignment_events must reference assignments in the same Workspace'); -END; - -CREATE TRIGGER ticket_assignment_operations_validate_insert -BEFORE INSERT ON ticket_assignment_operations -WHEN NOT EXISTS ( - SELECT 1 FROM typed_tickets AS ticket - WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id -) -BEGIN - SELECT RAISE(ABORT, 'ticket_assignment_operations must reference a Ticket in the same Workspace'); -END; -"#, - )?; - Ok(()) -} - -fn rebuild_workspace_scoped_references_from_resource_keys(conn: &Connection) -> Result<()> { - if table_exists(conn, "workspace_resource_human_keys")? { - conn.execute_batch( - r#" -INSERT OR IGNORE INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at -) -SELECT workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at -FROM workspace_resource_keys; -INSERT INTO workspace_resource_human_key_counters ( - workspace_id, resource_kind, next_sequence -) -SELECT workspace_id, resource_kind, next_sequence -FROM workspace_resource_key_counters -WHERE true -ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET - next_sequence = max(next_sequence, excluded.next_sequence); -DROP INDEX IF EXISTS idx_workspace_resource_keys_reverse; -DROP TABLE workspace_resource_keys; -DROP TABLE workspace_resource_key_counters; -"#, - )?; - } else { - conn.execute_batch( - r#" -DROP INDEX IF EXISTS idx_workspace_resource_keys_reverse; -ALTER TABLE workspace_resource_keys RENAME COLUMN resource_key TO human_key; -ALTER TABLE workspace_resource_keys RENAME TO workspace_resource_human_keys; -ALTER TABLE workspace_resource_key_counters RENAME TO workspace_resource_human_key_counters; -"#, - )?; - } - enforce_workspace_resource_foreign_keys(conn)?; - conn.execute_batch( - r#" -DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse; -ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys; -ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key; -ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters; -CREATE INDEX idx_workspace_resource_keys_reverse - ON workspace_resource_keys(workspace_id, resource_kind, resource_key); -"#, - )?; - Ok(()) -} - -pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) -> Result<()> { - let current = current_schema_version(conn)?; - for migration in MIGRATIONS.iter().filter(|migration| { - i64::from(migration.version) > current && i64::from(migration.version) <= through_version - }) { - if migration.version == 39 { - let resource_key_schema_current = table_exists(conn, "workspace_resource_keys")?; - if !resource_key_schema_current { - ticket::migrate_sqlite_ticket_schema_through(conn, 5).map_err(|error| { - Error::Store(format!( - "migration 39 Ticket schema preparation failed: {error}" - )) - })?; - } - if !table_exists(conn, "typed_tickets")? { - return Err(Error::Store( - "migration 39 Ticket schema preparation created no typed_tickets".to_string(), - )); - } - merge_request::migrate(conn).map_err(|error| { - Error::Store(format!( - "migration 39 Merge Request schema preparation failed: {error}" - )) - })?; - validate_workspace_resource_references(conn) - .map_err(|error| Error::Store(format!("migration 39 preflight failed: {error}")))?; - conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; - let result = (|| -> Result<()> { - let tx = conn.unchecked_transaction()?; - if resource_key_schema_current { - rebuild_workspace_scoped_references_from_resource_keys(&tx)?; - } else { - (migration.apply)(&tx)?; - } - if !table_exists(&tx, "typed_tickets")? { - return Err(Error::Store( - "migration 39 did not materialize `typed_tickets`".to_string(), - )); - } - let dangling_foreign_key: Option<(String, String)> = tx - .query_row( - "SELECT name, sql FROM sqlite_schema \ - WHERE type = 'table' AND (sql LIKE '%_v38%' OR sql LIKE '%_v39%') LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((child, sql)) = dangling_foreign_key { - return Err(Error::Store(format!( - "migration 39 left a temporary reference in `{child}`: {sql}" - ))); - } - let foreign_key_failures: i64 = tx - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .map_err(|error| { - Error::Store(format!( - "migration 39 could not evaluate foreign keys: {error}" - )) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "migration 39 found {foreign_key_failures} foreign key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .map_err(|error| { - Error::Store(format!("migration 39 version insert failed: {error}")) - })?; - tx.commit().map_err(|error| { - Error::Store(format!("migration 39 commit failed: {error}")) - })?; - Ok(()) - })(); - conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") - .map_err(|error| { - Error::Store(format!( - "migration 39 could not restore FK enforcement: {error}" - )) - })?; - result?; - continue; - } - - if migration.version == 43 { - // Other in-database authorities may reference the assignment history table. Keep - // those foreign keys on the canonical table name while rebuilding its role-aware - // schema, then validate the complete database before restoring FK enforcement. - conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; - let result = (|| -> Result<()> { - let tx = conn.unchecked_transaction()?; - (migration.apply)(&tx)?; - let dangling_reference: Option<(String, String)> = tx - .query_row( - "SELECT name, sql FROM sqlite_schema \ - WHERE sql LIKE '%ticket_worker_assignments_v43%' LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((object, sql)) = dangling_reference { - return Err(Error::Store(format!( - "migration 43 left a temporary Ticket assignment reference in `{object}`: {sql}" - ))); - } - let foreign_key_failures: i64 = tx - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .map_err(|error| { - Error::Store(format!( - "migration 43 could not evaluate foreign keys: {error}" - )) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "migration 43 found {foreign_key_failures} foreign key violation(s)" - ))); - } - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - )?; - tx.commit()?; - Ok(()) - })(); - conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") - .map_err(|error| { - Error::Store(format!( - "migration 43 could not restore FK enforcement: {error}" - )) - })?; - result?; - continue; - } - - if migration.version == 50 { - conn.execute_batch( - "PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON; BEGIN EXCLUSIVE;", - )?; - let result = (|| -> Result<()> { - (migration.apply)(conn)?; - let dangling_reference: Option<(String, String)> = conn - .query_row( - "SELECT name, sql FROM sqlite_schema \ - WHERE sql LIKE '%repositories_v50%' \ - OR sql LIKE '%repository_identity_v50%' LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((object, sql)) = dangling_reference { - return Err(Error::Store(format!( - "migration 50 left a temporary Repository reference in `{object}`: {sql}" - ))); - } - let foreign_key_failures: i64 = - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "migration 50 found {foreign_key_failures} foreign key violation(s)" - ))); - } - conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - )?; - conn.execute_batch("COMMIT;")?; - Ok(()) - })(); - if result.is_err() && !conn.is_autocommit() { - conn.execute_batch("ROLLBACK;")?; - } - conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") - .map_err(|error| { - Error::Store(format!( - "migration 50 could not restore FK enforcement: {error}" - )) - })?; - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?; - if foreign_keys_enabled != 1 { - return Err(Error::Store( - "migration 50 did not restore foreign key enforcement".to_string(), - )); - } - result?; - continue; - } - - if migration.version == 48 { - // Rebuilding the parent Workspace table requires FK enforcement to be disabled - // outside the transaction. The migration, verification, and schema marker still - // commit atomically as one exclusive operation. - conn.execute_batch( - "PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON; BEGIN EXCLUSIVE;", - )?; - let result = (|| -> Result<()> { - (migration.apply)(conn)?; - let dangling_reference: Option<(String, String)> = conn - .query_row( - "SELECT name, sql FROM sqlite_schema \ - WHERE sql LIKE '%workspaces_v48%' LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((object, sql)) = dangling_reference { - return Err(Error::Store(format!( - "migration 48 left a temporary Workspace reference in `{object}`: {sql}" - ))); - } - let foreign_key_failures: i64 = - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "migration 48 found {foreign_key_failures} foreign key violation(s)" - ))); - } - conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - )?; - conn.execute_batch("COMMIT;")?; - Ok(()) - })(); - if result.is_err() && !conn.is_autocommit() { - conn.execute_batch("ROLLBACK;")?; - } - conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") - .map_err(|error| { - Error::Store(format!( - "migration 48 could not restore FK enforcement: {error}" - )) - })?; - let foreign_keys_enabled = - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?; - if foreign_keys_enabled != 1 { - return Err(Error::Store( - "migration 48 did not restore foreign key enforcement".to_string(), - )); - } - result?; - continue; - } - - let tx = conn.unchecked_transaction()?; - if migration.version == 37 { - crate::retention::repair_worker_diagnostics_archive_table(&tx)?; - } - (migration.apply)(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - )?; - tx.commit()?; - } Ok(()) } fn apply_migrations(conn: &Connection) -> Result<()> { - let latest = i64::from(MIGRATIONS.last().expect("at least one migration").version); + let baseline = &MIGRATIONS[0]; let current = current_schema_version(conn)?; - if current > latest { - return Err(Error::Store(format!( - "database schema version {current} is newer than this server supports ({latest}); refusing to serve with an older binary" - ))); - } - apply_migrations_through(conn, latest) -} - -fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { - if table_exists(conn, "repositories")? - && column_exists(conn, "repositories", "local_root")? - && !column_exists(conn, "repositories", "uri")? - { - rename_legacy_table(conn, "repositories", "legacy_repositories")?; - } - if table_exists(conn, "runs")? { - rename_legacy_table(conn, "runs", "legacy_runs")?; - } - if table_exists(conn, "artifacts")? - && (column_exists(conn, "artifacts", "run_id")? - || column_exists(conn, "artifacts", "path")? - || !column_exists(conn, "artifacts", "uri")?) - { - rename_legacy_table(conn, "artifacts", "legacy_artifacts")?; - } - if table_exists(conn, "ticket_projections")? { - rename_legacy_table(conn, "ticket_projections", "legacy_ticket_projections")?; - } - if table_exists(conn, "objective_projections")? { - rename_legacy_table( - conn, - "objective_projections", - "legacy_objective_projections", - )?; - } - - let legacy_workspaces = preserve_noncanonical_workspaces(conn)?; - create_schema_v0_tables(conn)?; - if let Some(legacy_table) = legacy_workspaces { - copy_legacy_workspaces(conn, &legacy_table)?; - } - Ok(()) -} - -fn preserve_noncanonical_workspaces(conn: &Connection) -> Result> { - if !table_exists(conn, "workspaces")? { - return Ok(None); - } - let columns = table_columns(conn, "workspaces")?; - if columns - .iter() - .map(String::as_str) - .eq(WORKSPACES_V0_COLUMNS.iter().copied()) - { - return Ok(None); - } - let legacy_table = "legacy_workspaces"; - rename_legacy_table(conn, "workspaces", legacy_table)?; - Ok(Some(legacy_table.to_string())) -} - -fn copy_legacy_workspaces(conn: &Connection, legacy_table: &str) -> Result<()> { - let columns = table_columns(conn, legacy_table)?; - for required_column in ["workspace_id", "display_name", "created_at", "updated_at"] { - if !columns.iter().any(|column| column == required_column) { - return Err(Error::Store(format!( - "cannot migrate legacy workspaces: `{legacy_table}` is missing `{required_column}`" - ))); + match current { + 0 => { + let tx = conn.unchecked_transaction()?; + (baseline.apply)(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![baseline.version, baseline.name], + )?; + tx.commit()?; + Ok(()) } + version if version == baseline.version => verify_baseline_history(conn), + version if version > baseline.version => Err(Error::Store(format!( + "database schema version {version} is newer than this server supports ({}); refusing to serve with an older binary", + baseline.version + ))), + version => Err(Error::Store(format!( + "database schema version {version} predates the canonical baseline ({}); migrate its data manually before starting this server", + baseline.version + ))), } - let state_expr = if columns.iter().any(|column| column == "state") { - "COALESCE(NULLIF(state, ''), 'active')" - } else { - "'active'" - }; - conn.execute_batch(&format!( - r#"INSERT OR IGNORE INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at - ) - SELECT workspace_id, display_name, {state_expr}, created_at, updated_at - FROM {legacy_table};"# - ))?; - Ok(()) -} - -fn rename_legacy_table(conn: &Connection, table_name: &str, legacy_name: &str) -> Result<()> { - if table_exists(conn, legacy_name)? { - return Err(Error::Store(format!( - "cannot preserve legacy table `{table_name}` because `{legacy_name}` already exists" - ))); - } - conn.execute_batch(&format!( - "ALTER TABLE {table_name} RENAME TO {legacy_name};" - ))?; - Ok(()) -} - -fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<()> { - if !table_exists(conn, "worker_registry")? - || !table_columns(conn, "worker_registry")? - .iter() - .any(|column| column == "lifecycle_state") - { - return Ok(()); - } - - conn.execute_batch( - r#" - CREATE TABLE worker_registry_v4 ( - workspace_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - display_name TEXT NOT NULL, - profile TEXT, - retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), - transcript_ref TEXT, - session_ref TEXT, - summary_ref TEXT, - diagnostics_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, worker_id), - UNIQUE (workspace_id, runtime_id, runtime_worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT INTO worker_registry_v4 ( - workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, - retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, - created_at, updated_at - ) - SELECT - workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, - retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, - created_at, updated_at - FROM worker_registry; - DROP TABLE worker_registry; - ALTER TABLE worker_registry_v4 RENAME TO worker_registry; - CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated - ON worker_registry(workspace_id, updated_at DESC); - "#, - )?; - Ok(()) -} - -fn use_composite_worker_registry_keys(conn: &Connection) -> Result<()> { - if !table_exists(conn, "worker_registry")? - || !table_columns(conn, "worker_registry")? - .iter() - .any(|column| column == "worker_id") - { - return Ok(()); - } - - conn.execute_batch( - r#" - CREATE TABLE worker_registry_v5 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - display_name TEXT NOT NULL, - profile TEXT, - retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), - transcript_ref TEXT, - session_ref TEXT, - summary_ref TEXT, - diagnostics_ref TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT OR REPLACE INTO worker_registry_v5 ( - workspace_id, runtime_id, runtime_worker_id, display_name, profile, - retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, - created_at, updated_at - ) - SELECT - workspace_id, - runtime_id, - CASE - WHEN typeof(runtime_worker_id) = 'integer' THEN runtime_worker_id - WHEN runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(runtime_worker_id, 8) AS INTEGER) - WHEN runtime_worker_id GLOB '[0-9]*' THEN CAST(runtime_worker_id AS INTEGER) - ELSE rowid - END, - display_name, profile, - retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, - created_at, updated_at - FROM worker_registry; - - CREATE TABLE worker_workdir_links_v5 ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - runtime_worker_id INTEGER NOT NULL, - workdir_id TEXT NOT NULL, - role TEXT NOT NULL, - linked_at TEXT NOT NULL, - unlinked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role), - FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry_v5(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE - ); - INSERT OR REPLACE INTO worker_workdir_links_v5 ( - workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at - ) - SELECT - links.workspace_id, - registry.runtime_id, - CASE - WHEN typeof(registry.runtime_worker_id) = 'integer' THEN registry.runtime_worker_id - WHEN registry.runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(registry.runtime_worker_id, 8) AS INTEGER) - WHEN registry.runtime_worker_id GLOB '[0-9]*' THEN CAST(registry.runtime_worker_id AS INTEGER) - ELSE registry.rowid - END, - links.workdir_id, - links.role, - links.linked_at, - links.unlinked_at - FROM worker_workdir_links AS links - JOIN worker_registry AS registry - ON registry.workspace_id = links.workspace_id - AND registry.worker_id = links.worker_id; - - DROP TABLE worker_workdir_links; - DROP TABLE worker_registry; - ALTER TABLE worker_registry_v5 RENAME TO worker_registry; - ALTER TABLE worker_workdir_links_v5 RENAME TO worker_workdir_links; - CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated - ON worker_registry(workspace_id, updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker - ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC); - "#, - )?; - Ok(()) -} - -fn add_workdir_runtime_observation_states(conn: &Connection) -> Result<()> { - if !table_exists(conn, "workdir_registry")? { - return Ok(()); - } - - conn.execute_batch( - r#" - CREATE TABLE workdir_registry_v6 ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - selector TEXT, - resolved_commit TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), - cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT OR REPLACE INTO workdir_registry_v6 ( - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, - materialization_status, cleanliness, created_at, updated_at - ) - SELECT - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, - CASE materialization_status - WHEN 'missing' THEN 'not_found' - WHEN 'removed' THEN 'not_found' - ELSE materialization_status - END, - cleanliness, created_at, updated_at - FROM workdir_registry; - DROP TABLE workdir_registry; - ALTER TABLE workdir_registry_v6 RENAME TO workdir_registry; - CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated - ON workdir_registry(workspace_id, updated_at DESC); - "#, - )?; - Ok(()) -} - -fn remove_workdir_registry_management_kind_column(conn: &Connection) -> Result<()> { - if !table_exists(conn, "workdir_registry")? - || !table_columns(conn, "workdir_registry")? - .iter() - .any(|column| column == "management_kind") - { - return Ok(()); - } - - conn.execute_batch( - r#" - CREATE TABLE workdir_registry_v7 ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - selector TEXT, - resolved_commit TEXT, - materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), - cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); - INSERT OR REPLACE INTO workdir_registry_v7 ( - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, - materialization_status, cleanliness, created_at, updated_at - ) - SELECT - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, - materialization_status, cleanliness, created_at, updated_at - FROM workdir_registry; - DROP TABLE workdir_registry; - ALTER TABLE workdir_registry_v7 RENAME TO workdir_registry; - CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated - ON workdir_registry(workspace_id, updated_at DESC); - "#, - )?; - Ok(()) -} - -fn create_schema_v0_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE IF NOT EXISTS workspaces ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - state TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS objectives ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - state TEXT NOT NULL, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS objective_ticket_links ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - kind TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (objective_id, ticket_id, kind) -); - -CREATE TABLE IF NOT EXISTS objective_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, - event_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - body_md TEXT, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS objective_resources ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, - resource_path TEXT NOT NULL, - body TEXT NOT NULL, - media_type TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (objective_id, resource_path) -); - -CREATE TABLE IF NOT EXISTS memory_staging_records ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - candidate_id TEXT NOT NULL, - raw_json TEXT NOT NULL, - source_path TEXT, - imported_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, candidate_id) -); - -CREATE TABLE IF NOT EXISTS workspace_memory_documents ( - workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS memory_staging_resolutions ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - candidate_id TEXT NOT NULL, - action TEXT NOT NULL, - reason TEXT NOT NULL, - affected_refs_json TEXT NOT NULL, - staging_raw_json TEXT NOT NULL, - source_path TEXT, - imported_at TEXT NOT NULL, - resolved_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, candidate_id, resolved_at) -); - -CREATE TABLE IF NOT EXISTS repositories ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - repository_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - kind TEXT NOT NULL, - provider TEXT, - uri TEXT NOT NULL, - default_ref TEXT, - auth_ref_kind TEXT, - auth_ref_key TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS artifacts ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - artifact_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - uri TEXT NOT NULL, - media_type TEXT, - sha256 TEXT, - size_bytes INTEGER, - summary TEXT, - created_at TEXT NOT NULL, - created_by_kind TEXT NOT NULL, - created_by_key TEXT NOT NULL, - created_by_display TEXT NOT NULL, - created_by_source_kind TEXT, - created_by_source_key TEXT, - ticket_id TEXT, - objective_id TEXT, - event_id TEXT, - worker_ref_kind TEXT, - worker_ref_key TEXT, - worker_display TEXT, - repository_id TEXT, - source_kind TEXT, - source_revision TEXT -); - -CREATE TABLE IF NOT EXISTS audit_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - audit_event_id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - actor_kind TEXT NOT NULL, - actor_key TEXT NOT NULL, - actor_display TEXT NOT NULL, - actor_source_kind TEXT, - actor_source_key TEXT, - action TEXT NOT NULL, - target_kind TEXT NOT NULL, - target_id TEXT, - outcome TEXT NOT NULL, - request_id TEXT, - summary TEXT -); -"#, - )?; - Ok(()) } fn table_exists(conn: &Connection, table_name: &str) -> Result { @@ -10416,423 +6307,85 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result> { #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeSet; - - fn assign_explicit_test_workspace_owner(conn: &Connection) { - conn.execute( - "INSERT OR IGNORE INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', '1', '1')", - [], - ) - .unwrap(); - conn.execute( - "UPDATE workspaces SET owner_account_id = 'owner-account' \ - WHERE owner_account_id IS NULL", - [], - ) - .unwrap(); - } #[test] - fn schema_v49_upgrades_persisted_v48_workdir_fixture() { + fn fresh_database_uses_only_the_canonical_workspace_baseline() { let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server-v48.db"); - { - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 48).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); - assert!(!table_exists(&conn, "workdir_removal_operations").unwrap()); - conn.execute_batch( - r#" - INSERT INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1'); - INSERT INTO workspaces ( - workspace_id, owner_account_id, display_name, state, created_at, updated_at - ) VALUES ('workspace-a', 'owner-account', 'Workspace A', 'active', '1', '1'); - INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, - source_kind, source_uri, default_ref, source_revision, - source_fingerprint, observed_status, observed_at, created_at, updated_at - ) VALUES ( - 'workspace-a', 'repository-a', 'Repository A', 'git', 'local', '/repo-a', - 'local_path', '/repo-a', 'develop', 1, - 'sha256:source-a', 'unverified', NULL, '1', '1' - ); - INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, - creation_selector, creation_ref, creation_tree, - current_selector, current_ref, current_tree, - observed_at_epoch_seconds, materialization_status, cleanliness, - created_at, updated_at - ) VALUES ( - 'workspace-a', 'workdir-a', 'runtime-a', 'repository-a', - 'refs/heads/develop', 'abc', 'tree-a', - 'refs/heads/work', 'def', 'tree-b', - 1, 'present', 'clean', '1', '1' - ); - "#, - ) - .unwrap(); - } - + let path = temp.path().join("server.db"); let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 50); - assert!(table_exists(conn, "workdir_removal_operations")?); - let columns = table_columns(conn, "workdir_removal_operations")?; - for required in [ - "workspace_id", - "operation_id", - "request_fingerprint", - "workdir_id", - "runtime_id", - "repository_id", - "materialization_fingerprint", - "source_actor", - "reason", - "state", - "attempt_count", - "retryable", - "disposition", - "failure_category", - "attempt_owner_pid", - "attempt_owner_start_marker", - "created_at", - "updated_at", - "completed_at", - ] { - assert!( - columns.iter().any(|column| column == required), - "missing {required}" - ); - } - let preserved: (String, String, String) = conn.query_row( - "SELECT workdir.workspace_id, repository.repository_key, workdir.materialization_status \ - FROM workdir_registry AS workdir \ - JOIN repositories AS repository \ - ON repository.workspace_id = workdir.workspace_id \ - AND repository.repository_id = workdir.repository_id \ - WHERE workdir.workdir_id='workdir-a'", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let migrations = conn + .prepare("SELECT version, name FROM __yoi_schema_migrations ORDER BY version")? + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; assert_eq!( - preserved, - ( - "workspace-a".to_string(), - "repository-a".to_string(), - "present".to_string(), - ) + migrations, + vec![( + LATEST_SCHEMA_VERSION, + "workspace schema baseline".to_string() + )] ); - let foreign_key_failures: i64 = conn.query_row( - "SELECT count(*) FROM pragma_foreign_key_check", - [], - |row| row.get(0), - )?; + ticket::verify_sqlite_ticket_schema(conn)?; + merge_request::migrate(conn)?; + let foreign_key_failures: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; assert_eq!(foreign_key_failures, 0); Ok(()) }) .unwrap(); - - let workdir = store - .get_workdir_registry("workspace-a", "workdir-a") - .unwrap() - .unwrap(); - let intent = crate::workdir_removal::workdir_removal_intent( - &workdir, - "migration-test", - "remove migrated Workdir", - ) - .unwrap(); - let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); - assert_eq!(operation.workspace_id, "workspace-a"); - assert_eq!(operation.working_directory_id, "workdir-a"); } #[test] - fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() { + fn startup_rejects_prebaseline_workspace_history() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 43).unwrap(); conn.execute( - "INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) \ - VALUES ('workspace-a', 'Workspace A', 'active', '1', '1')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'legacy')", [], ) .unwrap(); - for (repository_id, uri, auth_kind, auth_key) in [ - ( - "remote", - "https://example.test/org/repository.git", - Some("secret_store"), - Some("legacy/key"), - ), - ("invalid", "relative/repository", Some("file"), Some("/key")), - ] { - conn.execute( - r#"INSERT INTO repositories( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - auth_ref_kind, auth_ref_key, created_at, updated_at - ) VALUES ('workspace-a', ?1, ?1, 'git', 'git', ?2, 'main', ?3, ?4, '1', '1')"#, - params![repository_id, uri, auth_kind, auth_key], - ) - .unwrap(); - } - - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let remote = conn - .query_row( - "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ - FROM repositories WHERE workspace_id = 'workspace-a' AND repository_key = 'remote'", - [], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )) - }, - ) - .unwrap(); - assert_eq!(remote.0, "https"); - assert_eq!(remote.1, "https://example.test/org/repository.git"); - assert_eq!(remote.2, 1); - assert!(remote.3.starts_with("sha256:")); - assert_eq!(remote.4, "unverified"); - - let invalid = conn - .query_row( - "SELECT source_kind, observed_status FROM repositories \ - WHERE workspace_id = 'workspace-a' AND repository_key = 'invalid'", - [], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .unwrap(); - assert_eq!(invalid, ("invalid".to_string(), "invalid".to_string())); + let error = apply_migrations(&conn).unwrap_err(); + assert!( + error + .to_string() + .contains("predates the canonical baseline") + ); } #[test] - fn startup_composes_ticket_migrations_when_control_plane_is_current() { + fn startup_rejects_noncanonical_history_at_the_baseline_version() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); - apply_migrations(&conn).unwrap(); - assert!(table_exists(&conn, "ticket_schema_migrations").unwrap()); - - let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - store - .with_conn(|conn| { - ticket::verify_sqlite_ticket_schema(conn)?; - let latest = conn.query_row( - "SELECT MAX(version) FROM ticket_schema_migrations", - [], - |row| row.get::<_, i64>(0), - )?; - assert_eq!(latest, ticket::LATEST_SQLITE_TICKET_SCHEMA_VERSION); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn migration_dry_run_repairs_missing_diagnostics_archive_without_mutating_source() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - { - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 36).unwrap(); - conn.execute_batch( - "DROP TABLE worker_diagnostics_archives; - INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) - VALUES ('workspace-a', 'Workspace A', 'active', '1', '1'); - INSERT INTO worker_registry( - workspace_id, runtime_id, runtime_worker_id, display_name, - retention_state, created_at, updated_at - ) VALUES ('workspace-a', 'runtime-a', 7, 'Worker 7', 'normal', '1', '1');", - ) - .unwrap(); - assign_explicit_test_workspace_owner(&conn); - } - let before = std::fs::read(&path).unwrap(); - let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); - assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 50); - assert!(plan.migration_required); - assert_eq!(plan.worker_count, 1); - assert_eq!(plan.mappings[0].legacy_worker_id, 7); - assert_eq!( - plan.repairs, - vec!["create missing worker_diagnostics_archives table"] - ); - assert_eq!(std::fs::read(&path).unwrap(), before); - - let store = SqliteWorkspaceStore::open(&path).unwrap(); - store - .with_conn(|conn| { - assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 50); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn migration_dry_run_accepts_non_worker_ticket_assignments() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - { - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations(&conn).unwrap(); - conn.execute_batch( - r#" - INSERT INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', '1', '1'); - INSERT INTO workspaces ( - workspace_id, owner_account_id, display_name, state, created_at, updated_at - ) VALUES ('workspace-a', 'owner-account', 'Workspace A', 'active', '1', '1'); - INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit - ) VALUES ( - 'workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', - 'normal', '', 'planning', 1 - ); - INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, role, principal_kind, - principal_id, runtime_id, worker_id, assigned_by, assigned_at - ) VALUES ( - 'workspace-a', 'ticket-a', 'assignment-a', 'orchestrator', - 'workspace_agent', 'workspace-orchestrator', NULL, NULL, 'tester', '2' - ); - INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, role, assignment_id, principal_kind, - principal_id, runtime_id, worker_id, updated_at - ) VALUES ( - 'workspace-a', 'ticket-a', 'orchestrator', 'assignment-a', - 'workspace_agent', 'workspace-orchestrator', NULL, NULL, '2' - ); - INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, role, principal_kind, - principal_id, runtime_id, worker_id, assignment_id, created_at - ) VALUES ( - 'workspace-a', 'operation-a', 'assign', 'ticket-a', 'orchestrator', - 'workspace_agent', 'workspace-orchestrator', NULL, NULL, 'assignment-a', '2' - ); - "#, - ) - .unwrap(); - } - - let before = std::fs::read(&path).unwrap(); - let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); - assert_eq!( - plan.current_schema_version, - i64::from(MIGRATIONS.last().expect("at least one migration").version) - ); - assert!(!plan.migration_required); - assert!(plan.repairs.is_empty()); - assert_eq!(std::fs::read(&path).unwrap(), before); - } - - #[test] - fn v38_backfills_workspace_scoped_objective_and_worker_resource_keys() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 37).unwrap(); conn.execute( - "INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) - VALUES ('workspace-a', 'Workspace A', 'active', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (1, 'legacy')", [], - ).unwrap(); - for (id, created_at) in [ - ("objective-later", "2026-01-02T00:00:00Z"), - ("objective-earlier", "2026-01-01T00:00:00Z"), - ] { - conn.execute( - "INSERT INTO objectives(workspace_id, objective_id, title, body_md, state, created_at, updated_at) - VALUES ('workspace-a', ?1, ?1, '', 'active', ?2, ?2)", - params![id, created_at], - ).unwrap(); - } - for (id, created_at) in [ - ( - "019b57c8-5c00-7000-8000-000000000002", - "2026-01-02T00:00:00Z", - ), - ( - "019b5280-0000-7000-8000-000000000001", - "2026-01-01T00:00:00Z", - ), - ] { - conn.execute( - "INSERT INTO worker_registry(workspace_id, worker_id, runtime_id, display_name, retention_state, created_at, updated_at) - VALUES ('workspace-a', ?1, 'runtime-a', ?1, 'normal', ?2, ?2)", - params![id, created_at], - ).unwrap(); - } - - ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&conn).unwrap(); - let mut statement = conn - .prepare( - "SELECT resource_kind, resource_id, resource_key FROM workspace_resource_keys - ORDER BY resource_kind, sequence", - ) - .unwrap(); - let keys = statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!( - keys, - vec![ - ("objective".into(), "objective-earlier".into(), "O-1".into()), - ("objective".into(), "objective-later".into(), "O-2".into()), - ( - "worker".into(), - "019b5280-0000-7000-8000-000000000001".into(), - "W-1".into() - ), - ( - "worker".into(), - "019b57c8-5c00-7000-8000-000000000002".into(), - "W-2".into() - ), - ] + ) + .unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![LATEST_SCHEMA_VERSION, "workspace schema baseline"], + ) + .unwrap(); + let error = apply_migrations(&conn).unwrap_err(); + assert!( + error + .to_string() + .contains("migration history is not the canonical schema baseline") ); - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let foreign_key_error: Option = conn - .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) - .optional() - .unwrap(); - assert!(foreign_key_error.is_none()); } #[test] fn startup_fails_closed_when_current_ticket_schema_has_drifted() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); - apply_migrations(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + apply_migrations(&conn).unwrap(); conn.execute_batch("DROP TABLE typed_ticket_artifacts") .unwrap(); @@ -10844,484 +6397,6 @@ mod tests { assert!(error.to_string().contains("typed_ticket_artifacts")); } - #[test] - fn removes_unused_control_plane_ticket_tables() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - r#" -CREATE TABLE tickets (ticket_id TEXT PRIMARY KEY); -CREATE TABLE ticket_events (event_id TEXT PRIMARY KEY, ticket_id TEXT REFERENCES tickets(ticket_id)); -CREATE TABLE ticket_relations (source_ticket_id TEXT, target_ticket_id TEXT); -CREATE TABLE ticket_targets (ticket_id TEXT, target_id TEXT, PRIMARY KEY (ticket_id, target_id)); -CREATE TABLE ticket_target_paths (ticket_id TEXT, target_id TEXT, path TEXT); -CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); -"#, - ) - .unwrap(); - remove_unused_control_plane_ticket_tables(&conn).unwrap(); - for table in [ - "tickets", - "ticket_events", - "ticket_relations", - "ticket_targets", - "ticket_target_paths", - "ticket_worker_links", - "ticket_notification_outbox", - "ticket_notification_deliveries", - "ticket_notification_cursors", - ] { - assert!(!table_exists(&conn, table).unwrap(), "{table} still exists"); - } - } - - #[test] - fn schema_v35_removes_worker_control_delegation_authority() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= 34) - { - let tx = conn.unchecked_transaction().unwrap(); - (migration.apply)(&tx).unwrap(); - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .unwrap(); - tx.commit().unwrap(); - } - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); - conn.execute_batch( - r#" -INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at -) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1'); -INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit -) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'normal', '', 'planning', 1); -INSERT INTO worker_registry ( - workspace_id, runtime_id, runtime_worker_id, display_name, - retention_state, created_at, updated_at -) VALUES - ('workspace-a', 'runtime-a', 1, 'Controller', 'normal', '1', '1'), - ('workspace-a', 'runtime-a', 2, 'Spawned Worker', 'normal', '1', '1'), - ('workspace-a', 'runtime-a', 3, 'Shared Worker', 'normal', '1', '1'), - ('workspace-a', 'runtime-a', 4, 'Transferred Worker', 'normal', '1', '1'); -INSERT INTO worker_control_grants ( - workspace_id, grant_id, - controller_runtime_id, controller_worker_id, - subject_runtime_id, subject_worker_id, - relation, origin, permissions_json, operation_id, created_at, revoked_at -) VALUES - ('workspace-a', 'spawned', 'runtime-a', 1, 'runtime-a', 2, - 'spawned', 'spawn', '["observe","share","transfer","revoke","stop"]', 'spawn-op', '1', NULL), - ('workspace-a', 'shared', 'runtime-a', 1, 'runtime-a', 3, - 'shared', 'share', '["observe"]', 'share-op', '1', NULL), - ('workspace-a', 'transferred', 'runtime-a', 1, 'runtime-a', 4, - 'transferred', 'transfer', '["observe"]', 'transfer-op', '1', NULL); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', '1', 'test', '1'); -INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at -) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', '1', '1'); -INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, runtime_id, worker_id, - assignment_id, request_fingerprint, created_at -) VALUES ( - 'workspace-a', 'assignment-operation-a', 'assign', 'ticket-a', - 'runtime-a', '1', 'assignment-a', 'sha256:test', '1' -); -INSERT INTO worker_orphan_diagnostics ( - diagnostic_id, workspace_id, runtime_id, worker_id, category, detail, observed_at -) VALUES ( - 'orphan-a', 'workspace-a', 'runtime-a', '9', 'runtime_aggregate_without_backend_registry', - 'legacy orphan', '1' -); -"#, - ) - .unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 34); - assert!(table_exists(&conn, "worker_control_delegation_operations").unwrap()); - - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); - let controller_worker_id: String = conn - .query_row( - "SELECT worker_id FROM worker_registry WHERE display_name = 'Controller'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - controller_worker_id, - WorkerId::from_legacy_binding("workspace-a", "runtime-a", 1).to_string() - ); - let (grant_controller, grant_subject): (String, String) = conn - .query_row( - "SELECT controller_worker_id, subject_worker_id \ - FROM worker_control_grants WHERE grant_id = 'spawned'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(grant_controller, controller_worker_id); - assert_eq!( - grant_subject, - WorkerId::from_legacy_binding("workspace-a", "runtime-a", 2).to_string() - ); - let expected_assignment_worker = - WorkerId::from_legacy_binding("workspace-a", "runtime-a", 1).to_string(); - for table in [ - "ticket_worker_assignments", - "ticket_current_worker_assignments", - "ticket_assignment_operations", - ] { - let sql = format!("SELECT worker_id FROM {table} WHERE ticket_id = 'ticket-a'"); - let migrated_worker_id: String = conn.query_row(&sql, [], |row| row.get(0)).unwrap(); - assert_eq!( - migrated_worker_id, expected_assignment_worker, - "{table} retained a legacy Worker id" - ); - } - let orphan_worker_id: String = conn - .query_row( - "SELECT worker_id FROM worker_orphan_diagnostics WHERE diagnostic_id = 'orphan-a'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - orphan_worker_id, - WorkerId::from_legacy_binding("workspace-a", "runtime-a", 9).to_string() - ); - let worker_registry_pk = { - let mut statement = conn.prepare("PRAGMA table_info(worker_registry)").unwrap(); - statement - .query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?)) - }) - .unwrap() - .filter_map(|row| { - let (name, position) = row.unwrap(); - (position > 0).then_some((position, name)) - }) - .collect::>() - }; - assert_eq!( - worker_registry_pk, - vec![ - (1, "workspace_id".to_string()), - (2, "worker_id".to_string()) - ] - ); - - let (permissions_json, revoked_at): (String, Option) = conn - .query_row( - "SELECT permissions_json, revoked_at FROM worker_control_grants WHERE grant_id = 'spawned'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!( - serde_json::from_str::>(&permissions_json).unwrap(), - vec!["observe", "stop"] - ); - assert!(revoked_at.is_none()); - for grant_id in ["shared", "transferred"] { - let revoked_at: Option = conn - .query_row( - "SELECT revoked_at FROM worker_control_grants WHERE grant_id = ?1", - [grant_id], - |row| row.get(0), - ) - .unwrap(); - assert!(revoked_at.is_some(), "{grant_id} grant remained active"); - } - } - - #[test] - fn schema_v24_adds_attachment_reservations_to_already_applied_v23() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= 23) - { - let tx = conn.unchecked_transaction().unwrap(); - (migration.apply)(&tx).unwrap(); - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .unwrap(); - tx.commit().unwrap(); - } - assert_eq!(current_schema_version(&conn).unwrap(), 23); - assert!(!table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); - - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); - } - - #[test] - fn schema_v42_initializes_existing_workspaces_with_explicit_english_memory_settings() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 41).unwrap(); - conn.execute( - "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) \ - VALUES ('workspace-existing', 'Existing', 'active', '1', '1')", - [], - ) - .unwrap(); - - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let settings = conn - .query_row( - "SELECT settings_revision, language FROM workspace_memory_settings \ - WHERE workspace_id = 'workspace-existing'", - [], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) - .unwrap(); - assert_eq!(settings, (1, "English".to_string())); - } - - #[test] - fn schema_v26_removes_legacy_backend_flow_runtime_tables() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= 25) - { - let tx = conn.unchecked_transaction().unwrap(); - (migration.apply)(&tx).unwrap(); - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .unwrap(); - tx.commit().unwrap(); - } - conn.execute_batch( - r#" -CREATE TABLE flow_instances (instance_id TEXT PRIMARY KEY); -CREATE TABLE flow_transition_attempts (attempt_id TEXT PRIMARY KEY); -CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); -"#, - ) - .unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 25); - - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - assert!(table_exists(&conn, "flow_sources").unwrap()); - assert!(table_exists(&conn, "flow_source_revisions").unwrap()); - assert!(!table_exists(&conn, "flow_instances").unwrap()); - assert!(!table_exists(&conn, "flow_transition_attempts").unwrap()); - assert!(!table_exists(&conn, "flow_events").unwrap()); - } - - #[test] - fn schema_v27_upgrades_repository_identity_without_losing_workspace_owned_references() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= 26) - { - let tx = conn.unchecked_transaction().unwrap(); - (migration.apply)(&tx).unwrap(); - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .unwrap(); - tx.commit().unwrap(); - } - conn.execute_batch( - r#" -INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at -) VALUES - ('workspace-a', 'Workspace A', 'active', '1', '1'), - ('workspace-b', 'Workspace B', 'active', '1', '1'); -INSERT INTO repositories ( - repository_id, workspace_id, name, kind, uri, created_at, updated_at -) VALUES ('main', 'workspace-a', 'Main', 'git', '/repo-a', '1', '1'); -INSERT INTO artifacts ( - workspace_id, artifact_id, kind, uri, created_at, - created_by_kind, created_by_key, created_by_display, repository_id -) VALUES ( - 'workspace-a', 'artifact-1', 'report', 'artifact://1', '1', - 'worker', 'worker-1', 'Worker 1', 'main' -); -INSERT INTO worker_registry ( - workspace_id, runtime_id, runtime_worker_id, display_name, - retention_state, created_at, updated_at -) VALUES ('workspace-a', 'runtime-a', 1, 'Worker 1', 'normal', '1', '1'); -INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, - creation_selector, creation_ref, materialization_status, - cleanliness, created_at, updated_at, current_selector, current_ref -) VALUES ( - 'workspace-a', 'workdir-1', 'runtime-a', 'main', - 'develop', 'abc', 'present', 'clean', '1', '1', 'develop', 'abc' -); -INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, runtime_worker_id, workdir_id, - role, linked_at, unlinked_at -) VALUES ('workspace-a', 'runtime-a', 1, 'workdir-1', 'attachment', '1', NULL); -INSERT INTO worker_workdir_attachment_reservations ( - workspace_id, workdir_id, reservation_id, reserved_at -) VALUES ('workspace-a', 'workdir-1', 'reservation-1', '1'); -"#, - ) - .unwrap(); - - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let repositories_sql: String = conn - .query_row( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(repositories_sql.contains("repository_id TEXT NOT NULL PRIMARY KEY")); - assert!(repositories_sql.contains("UNIQUE(workspace_id, repository_key)")); - let preserved: (i64, i64, i64) = ( - conn.query_row("SELECT COUNT(*) FROM artifacts", [], |row| row.get(0)) - .unwrap(), - conn.query_row("SELECT COUNT(*) FROM worker_workdir_links", [], |row| { - row.get(0) - }) - .unwrap(), - conn.query_row( - "SELECT COUNT(*) FROM worker_workdir_attachment_reservations", - [], - |row| row.get(0), - ) - .unwrap(), - ); - assert_eq!(preserved, (1, 1, 1)); - let foreign_key_violations: i64 = conn - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(foreign_key_violations, 0); - conn.execute( - r#"INSERT INTO repositories ( - workspace_id, repository_id, repository_key, kind, provider, uri, default_ref, - created_at, updated_at, source_kind, source_uri, source_revision, - source_fingerprint, observed_status - ) VALUES ( - 'workspace-b', '01890f47-3c22-7cc0-98c4-dc0c0c07398f', 'main', - 'git', 'local', '/repo-b', 'HEAD', '2', '2', 'local', '/repo-b', 1, - 'sha256:test', 'unverified' - )"#, - [], - ) - .unwrap(); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM repositories WHERE repository_key = 'main'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 2 - ); - assert!( - conn.execute( - r#"INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, - materialization_status, cleanliness, created_at, updated_at - ) VALUES ('workspace-b', 'invalid', 'runtime-b', 'missing', - 'present', 'clean', '2', '2')"#, - [], - ) - .is_err() - ); - assert!( - conn.execute( - r#"INSERT INTO artifacts ( - workspace_id, artifact_id, kind, uri, created_at, - created_by_kind, created_by_key, created_by_display, repository_id - ) VALUES ('workspace-b', 'invalid-artifact', 'report', 'artifact://invalid', '2', - 'worker', 'worker-2', 'Worker 2', 'missing')"#, - [], - ) - .is_err() - ); - } - - #[test] - fn schema_v27_rejects_cross_workspace_legacy_repository_references() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - for migration in MIGRATIONS - .iter() - .filter(|migration| migration.version <= 26) - { - let tx = conn.unchecked_transaction().unwrap(); - (migration.apply)(&tx).unwrap(); - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![migration.version, migration.name], - ) - .unwrap(); - tx.commit().unwrap(); - } - conn.execute_batch( - r#" -INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at -) VALUES - ('workspace-a', 'Workspace A', 'active', '1', '1'), - ('workspace-b', 'Workspace B', 'active', '1', '1'); -INSERT INTO repositories ( - repository_id, workspace_id, name, kind, uri, created_at, updated_at -) VALUES ('main', 'workspace-a', 'Main', 'git', '/repo-a', '1', '1'); -INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, - materialization_status, cleanliness, created_at, updated_at -) VALUES ('workspace-b', 'foreign-workdir', 'runtime-b', 'main', - 'present', 'clean', '1', '1'); -"#, - ) - .unwrap(); - - let error = apply_migrations(&conn).unwrap_err(); - - assert!(error.to_string().contains("workdir_registry")); - assert!(error.to_string().contains("workspace-b")); - assert_eq!(current_schema_version(&conn).unwrap(), 26); - assert_eq!( - conn.query_row("SELECT COUNT(*) FROM workdir_registry", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 1 - ); - } - #[tokio::test] async fn workspace_schema_rejects_cross_workspace_ticket_repository_reference_at_write_time() { let dir = tempfile::tempdir().unwrap(); @@ -11986,255 +7061,6 @@ INSERT INTO worker_registry ( ); } - #[tokio::test] - async fn schema_v43_migrates_legacy_current_assignment_to_coder_worker_role() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("legacy-v42.db"); - let conn = Connection::open(&db_path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 42).unwrap(); - let legacy = SqliteWorkspaceStore { - conn: Arc::new(Mutex::new(conn)), - }; - legacy - .upsert_workspace(&WorkspaceRecord { - workspace_id: "workspace-legacy".to_string(), - display_name: "Legacy".to_string(), - state: "active".to_string(), - owner_account_id: "owner-account".to_string(), - created_at: "2026-09-01T00:00:00Z".to_string(), - updated_at: "2026-09-01T00:00:00Z".to_string(), - }) - .await - .unwrap(); - legacy - .upsert_worker_registry(&WorkerRegistryRecord { - workspace_id: "workspace-legacy".to_string(), - worker: RuntimeWorkerRef::new("runtime-legacy", "worker-legacy"), - display_name: "Legacy Coder".to_string(), - profile: Some("builtin:coder".to_string()), - retention_state: "normal".to_string(), - transcript_ref: None, - session_ref: None, - summary_ref: None, - diagnostics_ref: None, - created_at: "2026-09-01T00:00:00Z".to_string(), - updated_at: "2026-09-01T00:00:00Z".to_string(), - }) - .unwrap(); - let backend = - ticket::SqliteTicketBackend::open_verified(&db_path, "workspace-legacy").unwrap(); - let mut input = ticket::NewTicket::new("Legacy Ticket"); - input.workflow_state = Some(ticket::TicketWorkflowState::InProgress); - let ticket = ticket::TicketBackend::create(&backend, input).unwrap(); - legacy - .with_conn(|conn| { - conn.execute( - "INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, - assigned_by, assigned_at - ) VALUES ('workspace-legacy', ?1, 'assignment-legacy', - 'runtime-legacy', 'worker-legacy', 'legacy', - '2026-09-01T00:01:00Z')", - params![ticket.id], - )?; - conn.execute( - "INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at - ) VALUES ('workspace-legacy', ?1, 'assignment-legacy', - 'runtime-legacy', 'worker-legacy', '2026-09-01T00:01:00Z')", - params![ticket.id], - )?; - Ok(()) - }) - .unwrap(); - drop(legacy); - - let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); - assert_eq!( - migrated - .get_current_ticket_role_assignment( - "workspace-legacy", - &ticket.id, - TicketAssignmentRole::Coder, - ) - .unwrap(), - Some(TicketRoleAssignmentRecord { - workspace_id: "workspace-legacy".to_string(), - ticket_id: ticket.id, - assignment_id: "assignment-legacy".to_string(), - role: TicketAssignmentRole::Coder, - principal: TicketAssignmentPrincipal::Worker { - runtime_id: "runtime-legacy".to_string(), - worker_id: "worker-legacy".to_string(), - }, - assigned_by: "legacy".to_string(), - assigned_at: "2026-09-01T00:01:00Z".to_string(), - }) - ); - } - - #[test] - fn schema_v43_clears_tombstoned_legacy_current_assignment_pointers() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("legacy-v42.db"); - let conn = Connection::open(&db_path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 42).unwrap(); - conn.execute_batch( - r#" - INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at - ) VALUES ('workspace-legacy', 'Legacy', 'active', '2026-09-01', '2026-09-01'); - INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit - ) VALUES - ('workspace-legacy', 'ticket-deleted-worker', 'deleted-worker', - 'Deleted Worker', 'open', 'task', 'normal', '', 'in_progress', 1), - ('workspace-legacy', 'ticket-deleted-ticket', 'deleted-ticket', - 'Deleted Ticket', 'open', 'task', 'normal', '', 'in_progress', 1); - INSERT INTO worker_registry ( - workspace_id, runtime_id, worker_id, display_name, retention_state, - created_at, updated_at - ) VALUES - ('workspace-legacy', 'runtime-legacy', - '00000000-0000-7000-8000-000000000001', 'Deleted Worker', 'normal', - '2026-09-01', '2026-09-01'), - ('workspace-legacy', 'runtime-legacy', - '00000000-0000-7000-8000-000000000002', 'Retained Worker', 'normal', - '2026-09-01', '2026-09-01'); - INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, - assigned_by, assigned_at - ) VALUES - ('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker', - 'runtime-legacy', '00000000-0000-7000-8000-000000000001', - 'legacy', '2026-09-01'), - ('workspace-legacy', 'ticket-deleted-ticket', 'assignment-deleted-ticket', - 'runtime-legacy', '00000000-0000-7000-8000-000000000002', - 'legacy', '2026-09-01'); - INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at - ) VALUES - ('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker', - 'runtime-legacy', '00000000-0000-7000-8000-000000000001', '2026-09-01'), - ('workspace-legacy', 'ticket-deleted-ticket', 'assignment-deleted-ticket', - 'runtime-legacy', '00000000-0000-7000-8000-000000000002', '2026-09-01'); - INSERT INTO ticket_worker_assignment_events ( - workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at - ) VALUES - ('workspace-legacy', 'ticket-deleted-worker', 'event-deleted-worker', - 'assigned', 'assignment-deleted-worker', 'legacy', '2026-09-01'), - ('workspace-legacy', 'ticket-deleted-ticket', 'event-deleted-ticket', - 'assigned', 'assignment-deleted-ticket', 'legacy', '2026-09-01'); - CREATE TABLE legacy_assignment_consumer ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - FOREIGN KEY (workspace_id, ticket_id, assignment_id) - REFERENCES ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id - ) - ); - INSERT INTO legacy_assignment_consumer - VALUES ('workspace-legacy', 'ticket-deleted-worker', 'assignment-deleted-worker'); - DELETE FROM worker_registry - WHERE workspace_id = 'workspace-legacy' - AND worker_id = '00000000-0000-7000-8000-000000000001'; - DELETE FROM typed_tickets - WHERE workspace_id = 'workspace-legacy' - AND ticket_id = 'ticket-deleted-ticket'; - "#, - ) - .unwrap(); - assign_explicit_test_workspace_owner(&conn); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 2 - ); - drop(conn); - - let plan = SqliteWorkspaceStore::migration_plan(&db_path).unwrap(); - assert!(plan.repairs.iter().any(|repair| { - repair == "clear 2 tombstoned legacy current Ticket assignment pointer(s)" - })); - - let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); - migrated - .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 50); - assert_eq!( - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, - 1, - "migration must restore foreign key enforcement" - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments", - [], - |row| row.get::<_, i64>(0), - )?, - 0 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments WHERE role = 'coder'", - [], - |row| row.get::<_, i64>(0), - )?, - 2, - "migration must retain assignment history" - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignment_events WHERE role = 'coder'", - [], - |row| row.get::<_, i64>(0), - )?, - 2, - "migration must retain assignment events" - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_assignment_worker_tombstones", - [], - |row| row.get::<_, i64>(0), - )?, - 1 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_assignment_ticket_tombstones", - [], - |row| row.get::<_, i64>(0), - )?, - 1 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM legacy_assignment_consumer", - [], - |row| row.get::<_, i64>(0), - )?, - 1, - "migration must preserve external assignment history references" - ); - let foreign_key_failures: i64 = - conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - })?; - assert_eq!(foreign_key_failures, 0); - Ok(()) - }) - .unwrap(); - } - #[tokio::test] async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() { let dir = tempfile::tempdir().unwrap(); @@ -12511,302 +7337,10 @@ INSERT INTO worker_registry ( ); } - #[test] - fn schema_v45_adds_workdir_create_operations_to_v44_database() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 44).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 44); - - apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 50); - assert!(table_exists(&conn, "workdir_create_operations").unwrap()); - let columns = table_columns(&conn, "workdir_create_operations").unwrap(); - for required in [ - "operation_id", - "request_fingerprint", - "resolved_runtime_id", - "config_revision", - "config_projection_digest", - "working_directory_id", - "state", - ] { - assert!( - columns.iter().any(|column| column == required), - "missing column {required}" - ); - } - } - - #[test] - fn schema_v46_adds_repository_ssh_secret_authority_to_v45_database() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 45).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 45); - - apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 50); - for table in [ - "repository_ssh_credentials", - "repository_ssh_credential_revisions", - "server_secret_versions", - "repository_ssh_host_trusts", - "repository_ssh_host_trust_revisions", - "repository_secret_operations", - "repository_secret_audit_events", - ] { - assert!(table_exists(&conn, table).unwrap(), "missing table {table}"); - } - let foreign_key_error: Option = conn - .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) - .optional() - .unwrap(); - assert!(foreign_key_error.is_none()); - } - - #[test] - fn schema_v47_binds_workdir_create_repository_access_evidence() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 46).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 46); - - apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let columns = table_columns(&conn, "workdir_create_operations").unwrap(); - for required in [ - "source_kind", - "source_uri", - "source_revision", - "source_fingerprint", - "credential_id", - "credential_revision", - "host_trust_id", - "host_trust_revision", - "repository_access_mode", - "cache_generation", - ] { - assert!( - columns.iter().any(|column| column == required), - "missing column {required}" - ); - } - let workdir_columns = table_columns(&conn, "workdir_registry").unwrap(); - for required in ["creation_tree", "current_tree", "observed_at_epoch_seconds"] { - assert!( - workdir_columns.iter().any(|column| column == required), - "missing column {required}" - ); - } - } - - #[test] - fn schema_v50_rekeys_repositories_and_same_workspace_references() { - let conn = workspace_owner_schema_47(); - apply_migrations_through(&conn, 49).unwrap(); - conn.execute_batch( - r#" - INSERT INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1'); - INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at, owner_account_id - ) VALUES - ('workspace-a', 'Workspace A', 'active', '1', '1', 'owner-account'), - ('workspace-b', 'Workspace B', 'active', '1', '1', 'owner-account'); - INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - created_at, updated_at, source_kind, source_uri, source_revision, - source_fingerprint, observed_status - ) VALUES - ('workspace-a', 'main', 'Legacy A', 'git', 'git', '/repo-a', 'develop', - '1', '1', 'local_path', '/repo-a', 1, 'sha256:a', 'unverified'), - ('workspace-b', 'main', 'Legacy B', 'git', 'git', '/repo-b', 'develop', - '1', '1', 'local_path', '/repo-b', 1, 'sha256:b', 'unverified'); - CREATE TABLE legacy_v5_merge_requests ( - workspace_id TEXT NOT NULL, - merge_request_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - PRIMARY KEY (workspace_id, merge_request_id), - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories(workspace_id, repository_id) - ); - INSERT INTO legacy_v5_merge_requests ( - workspace_id, merge_request_id, repository_id - ) VALUES ('workspace-a', 'legacy-mr-a', 'main'); - INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit, repository_id - ) VALUES - ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'normal', '', 'planning', 1, 'main'), - ('workspace-b', 'ticket-b', 'ticket-b', 'Ticket B', 'open', 'task', 'normal', '', 'planning', 1, 'main'); - INSERT INTO merge_requests ( - workspace_id, merge_request_id, repository_id, state, - selector_from, selector_to, created_at, updated_at - ) VALUES - ('workspace-a', 'mr-a', 'main', 'open', 'work/a', 'develop', '1', '1'), - ('workspace-b', 'mr-b', 'main', 'open', 'work/b', 'develop', '1', '1'); - INSERT INTO artifacts ( - workspace_id, artifact_id, kind, uri, created_at, - created_by_kind, created_by_key, created_by_display, repository_id - ) VALUES - ('workspace-a', 'artifact-a', 'report', 'artifact://a', '1', - 'worker', 'W-1', 'Worker 1', 'main'), - ('workspace-b', 'artifact-b', 'report', 'artifact://b', '1', - 'worker', 'W-2', 'Worker 2', 'main'); - INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, - materialization_status, cleanliness, created_at, updated_at - ) VALUES - ('workspace-a', 'workdir-a', 'runtime-a', 'main', 'present', 'clean', '1', '1'), - ('workspace-b', 'workdir-b', 'runtime-b', 'main', 'present', 'clean', '1', '1'); - "#, - ) - .unwrap(); - - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let repositories = { - let mut stmt = conn - .prepare( - "SELECT workspace_id, repository_id, repository_key FROM repositories ORDER BY workspace_id", - ) - .unwrap(); - stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - }) - .unwrap() - .collect::>>() - .unwrap() - }; - assert_eq!(repositories.len(), 2); - assert_eq!(repositories[0].2, "main"); - assert_eq!(repositories[1].2, "main"); - assert_ne!(repositories[0].1, repositories[1].1); - let legacy_repository_id: String = conn - .query_row( - "SELECT repository_id FROM legacy_v5_merge_requests WHERE workspace_id = 'workspace-a' AND merge_request_id = 'legacy-mr-a'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(legacy_repository_id, repositories[0].1); - for (_, repository_id, _) in &repositories { - assert_eq!(Uuid::parse_str(repository_id).unwrap().get_version_num(), 7); - } - for (workspace_id, repository_id, _) in &repositories { - let artifact_repository_id: String = conn - .query_row( - "SELECT repository_id FROM artifacts WHERE workspace_id = ?1", - params![workspace_id], - |row| row.get(0), - ) - .unwrap(); - let workdir_repository_id: String = conn - .query_row( - "SELECT repository_id FROM workdir_registry WHERE workspace_id = ?1", - params![workspace_id], - |row| row.get(0), - ) - .unwrap(); - let ticket_repository_id: String = conn - .query_row( - "SELECT repository_id FROM typed_tickets WHERE workspace_id = ?1", - params![workspace_id], - |row| row.get(0), - ) - .unwrap(); - let merge_request_repository_id: String = conn - .query_row( - "SELECT repository_id FROM merge_requests WHERE workspace_id = ?1", - params![workspace_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(&artifact_repository_id, repository_id); - assert_eq!(&workdir_repository_id, repository_id); - assert_eq!(&ticket_repository_id, repository_id); - assert_eq!(&merge_request_repository_id, repository_id); - } - assert!( - table_columns(&conn, "repositories") - .unwrap() - .iter() - .all(|column| column != "name") - ); - let foreign_key_failures: i64 = conn - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(foreign_key_failures, 0); - } - - #[test] - fn schema_v50_preflight_rolls_back_invalid_legacy_repository_key() { - let conn = workspace_owner_schema_47(); - apply_migrations_through(&conn, 49).unwrap(); - conn.execute_batch( - r#" - INSERT INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1'); - INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at, owner_account_id - ) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1', 'owner-account'); - INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, - created_at, updated_at, source_kind, source_uri, source_revision, - source_fingerprint, observed_status - ) VALUES ( - 'workspace-a', 'Invalid_Key', 'Legacy', 'git', 'git', '/repo', 'develop', - '1', '1', 'local_path', '/repo', 1, 'sha256:a', 'unverified' - ); - "#, - ) - .unwrap(); - let schema_before: String = conn - .query_row( - "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'repositories'", - [], - |row| row.get(0), - ) - .unwrap(); - - let error = apply_migrations(&conn).unwrap_err().to_string(); - - assert!(error.contains("Invalid_Key"), "{error}"); - assert!(error.contains("lowercase ASCII"), "{error}"); - assert_eq!(current_schema_version(&conn).unwrap(), 49); - let schema_after: String = conn - .query_row( - "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'repositories'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(schema_after, schema_before); - let persisted: (String, String) = conn - .query_row( - "SELECT repository_id, name FROM repositories WHERE workspace_id = 'workspace-a'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(persisted, ("Invalid_Key".to_string(), "Legacy".to_string())); - } - #[test] fn server_refuses_a_database_from_a_newer_schema_generation() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); - apply_migrations(&conn).unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (51, 'future')", [], @@ -12921,936 +7455,6 @@ INSERT INTO worker_registry ( .unwrap(); } - #[test] - fn migration_plan_lists_workspace_reference_violations_without_mutating_source() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); - merge_request::migrate(&conn).unwrap(); - conn.execute_batch( - r#" -INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) VALUES - ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), - ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); -INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit -) VALUES - ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'planning', 1), - ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', 'planning', 1); -INSERT INTO typed_ticket_relations (workspace_id, ticket_id, kind, target, note, author, at) -VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); -"#, - ) - .unwrap(); - drop(conn); - - let error = SqliteWorkspaceStore::migration_plan(&path) - .unwrap_err() - .to_string(); - assert!(error.contains("typed_ticket_relations.target"), "{error}"); - assert!( - error.contains("workspace-b/ticket-b -> ticket-a"), - "{error}" - ); - - let source = Connection::open(&path).unwrap(); - assert_eq!(current_schema_version(&source).unwrap(), 38); - assert_eq!( - source - .query_row("SELECT COUNT(*) FROM typed_ticket_relations", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap(), - 1 - ); - } - - #[test] - fn workspace_resource_fk_migration_rolls_back_constraint_failures() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); - merge_request::migrate(&conn).unwrap(); - conn.execute( - "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) \ - VALUES ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, \ - workflow_state, workflow_state_explicit, repository_id, ref_selector) \ - VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', \ - 'planning', 1, NULL, 'develop')", - [], - ) - .unwrap(); - - conn.execute_batch("CREATE TABLE typed_tickets_v39 (sentinel TEXT)") - .unwrap(); - - let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); - assert!(error.contains("CREATE TABLE typed_tickets_v39"), "{error}"); - assert_eq!(current_schema_version(&conn).unwrap(), 38); - assert_eq!( - conn.query_row( - "SELECT ref_selector FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "develop" - ); - assert!(table_exists(&conn, "typed_tickets_v39").unwrap()); - assert!(!table_exists(&conn, "objectives_v39").unwrap()); - let foreign_keys_enabled: i64 = conn - .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) - .unwrap(); - assert_eq!(foreign_keys_enabled, 1); - } - - #[test] - fn schema_v40_adds_workspace_create_operations_and_fail_closed_runtime_assignment() { - let mut conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 39).unwrap(); - let now = "2026-01-01T00:00:00Z"; - conn.execute( - r#"INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at - ) VALUES ('workspace-a', 'Workspace A', 'active', ?1, ?1)"#, - params![now], - ) - .unwrap(); - conn.execute( - r#"INSERT INTO trusted_runtime_records ( - runtime_id, display_name, base_url, public_key, created_at, updated_at - ) VALUES ('runtime-a', 'Runtime A', 'http://runtime-a.test', 'key', ?1, ?1)"#, - params![now], - ) - .unwrap(); - - assign_explicit_test_workspace_owner(&conn); - apply_migrations(&mut conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 50); - let workspace_id: Option = conn - .query_row( - "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(workspace_id, None); - assert!(table_exists(&conn, "workspace_create_operations").unwrap()); - } - - #[test] - fn workspace_resource_fk_migration_preflights_and_enforces_composite_identity() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); - merge_request::migrate(&conn).unwrap(); - - conn.execute_batch( - r#" -INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at -) VALUES - ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), - ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); -INSERT INTO repositories ( - workspace_id, repository_id, name, kind, provider, uri, default_ref, created_at, updated_at -) VALUES ('workspace-a', 'repo-a', 'Repo A', 'git', 'git', '/repo-a', 'develop', '2026-01-01', '2026-01-01'); -INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - created_at, updated_at, workflow_state, workflow_state_explicit, - repository_id, ref_selector -) VALUES - ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', - '2026-01-01', '2026-01-01', 'planning', 1, 'repo-a', 'develop'), - ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', - '2026-01-01', '2026-01-01', 'planning', 1, NULL, NULL), - ('workspace-b', 'ticket-b2', 'ticket-b2', 'B2', 'open', 'task', 'normal', '', - '2026-01-01', '2026-01-01', 'planning', 1, NULL, NULL); -INSERT INTO objectives ( - workspace_id, objective_id, title, state, body_md, created_at, updated_at -) VALUES ('workspace-a', 'objective-a', 'A', 'active', '', '2026-01-01', '2026-01-01'); -INSERT INTO typed_ticket_relations ( - workspace_id, ticket_id, kind, target, note, author, at -) VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); -INSERT INTO objective_ticket_links ( - workspace_id, objective_id, ticket_id, kind, created_at -) VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01'); -INSERT INTO worker_registry ( - workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at -) VALUES - ('workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001', 'Worker A', 'normal', '2026-01-01', '2026-01-01'), - ('workspace-b', 'runtime-b', '00000000-0000-7000-8000-000000000002', 'Worker B', 'normal', '2026-01-01', '2026-01-01'); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ( - 'workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', - '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01' -); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ( - 'workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong', - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' -); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ( - 'workspace-b', 'ticket-missing', 'assignment-missing-parents', 'runtime-missing', - '00000000-0000-7000-8000-000000000003', 'tester', '2026-01-01' -); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ( - 'workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' -); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES ( - 'workspace-b', 'ticket-b', 'assignment-event-source', 'runtime-b', - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' -); -INSERT INTO ticket_worker_assignment_events ( - workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at -) VALUES ( - 'workspace-b', 'ticket-b2', 'event-cross-ticket', 'assigned', - 'assignment-event-source', 'tester', '2026-01-01' -); -"#, - ) - .unwrap(); - - let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); - assert!(error.contains("typed_ticket_relations.target"), "{error}"); - assert!( - error.contains("workspace-b/ticket-b -> ticket-a"), - "{error}" - ); - assert!( - error.contains("objective_ticket_links.objective_id"), - "{error}" - ); - assert!( - error.contains("ticket_worker_assignments.ticket_id"), - "{error}" - ); - assert!(error.contains("assignment-cross-ticket"), "{error}"); - assert!( - error.contains("ticket_worker_assignments.worker_id"), - "{error}" - ); - assert!(error.contains("assignment-cross-worker"), "{error}"); - assert!(error.contains("assignment-missing-parents"), "{error}"); - assert!( - error.contains("ticket_worker_assignment_events.assignment_id"), - "{error}" - ); - assert!(error.contains("event-cross-ticket"), "{error}"); - assert_eq!(current_schema_version(&conn).unwrap(), 38); - - conn.execute("DELETE FROM typed_ticket_relations", []) - .unwrap(); - conn.execute("DELETE FROM objective_ticket_links", []) - .unwrap(); - conn.execute("DELETE FROM ticket_worker_assignment_events", []) - .unwrap(); - conn.execute("DELETE FROM ticket_worker_assignments", []) - .unwrap(); - apply_migrations_through(&conn, 39).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 39); - - let bad_target = conn.execute( - "UPDATE typed_tickets SET repository_id = 'repo-a', ref_selector = 'develop' \ - WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", - [], - ); - assert!(bad_target.is_err()); - let cross_relation = conn.execute( - "INSERT INTO typed_ticket_relations \ - (workspace_id, ticket_id, kind, target, note, author, at) \ - VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01')", - [], - ); - assert!(cross_relation.is_err()); - let cross_objective_link = conn.execute( - "INSERT INTO objective_ticket_links \ - (workspace_id, objective_id, ticket_id, kind, created_at) \ - VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01')", - [], - ); - assert!(cross_objective_link.is_err()); - let cross_ticket_assignment = conn.execute( - "INSERT INTO ticket_worker_assignments \ - (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ - VALUES ('workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', \ - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", - [], - ); - assert!(cross_ticket_assignment.is_err()); - let cross_worker_assignment = conn.execute( - "INSERT INTO ticket_worker_assignments \ - (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ - VALUES ('workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', \ - '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01')", - [], - ); - assert!(cross_worker_assignment.is_err()); - let runtime_mismatch_assignment = conn.execute( - "INSERT INTO ticket_worker_assignments \ - (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ - VALUES ('workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong', \ - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", - [], - ); - assert!(runtime_mismatch_assignment.is_err()); - conn.execute( - "INSERT INTO ticket_worker_assignments \ - (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ - VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-b', \ - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", - [], - ) - .unwrap(); - let mismatched_current_assignment = conn.execute( - "INSERT INTO ticket_current_worker_assignments \ - (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at) \ - VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-a', \ - '00000000-0000-7000-8000-000000000001', '2026-01-01')", - [], - ); - assert!(mismatched_current_assignment.is_err()); - let cross_assignment_event = conn.execute( - "INSERT INTO ticket_worker_assignment_events \ - (workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at) \ - VALUES ('workspace-b', 'ticket-b2', 'event-cross-assignment', 'assigned', \ - 'assignment-b', 'tester', '2026-01-01')", - [], - ); - assert!(cross_assignment_event.is_err()); - let cross_operation_ticket = conn.execute( - "INSERT INTO ticket_assignment_operations \ - (workspace_id, operation_id, action, ticket_id, created_at) \ - VALUES ('workspace-b', 'operation-cross-ticket', 'assign', 'ticket-a', '2026-01-01')", - [], - ); - assert!(cross_operation_ticket.is_err()); - assert!( - conn.execute( - "DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'", - [], - ) - .is_err() - ); - conn.execute( - "INSERT INTO typed_ticket_relations \ - (workspace_id, ticket_id, kind, target, note, author, at) \ - VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-b2', NULL, 'tester', '2026-01-01')", - [], - ) - .unwrap(); - conn.execute( - "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b2'", - [], - ) - .unwrap(); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM typed_ticket_relations WHERE workspace_id = 'workspace-b'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 0 - ); - conn.execute( - "DELETE FROM worker_registry WHERE workspace_id = 'workspace-b' AND worker_id = '00000000-0000-7000-8000-000000000002'", - [], - ) - .unwrap(); - conn.execute( - "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", - [], - ) - .unwrap(); - validate_workspace_resource_references(&conn).unwrap(); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 1, - "historical assignments survive Worker and Ticket retention deletion" - ); - conn.execute( - "DELETE FROM workspaces WHERE workspace_id = 'workspace-b'", - [], - ) - .unwrap(); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM typed_tickets WHERE workspace_id = 'workspace-b'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 0 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 0 - ); - - let foreign_key_failures: i64 = conn - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(foreign_key_failures, 0); - let integrity: String = conn - .query_row("PRAGMA integrity_check", [], |row| row.get(0)) - .unwrap(); - assert_eq!(integrity, "ok"); - } - - #[test] - fn workspace_resource_fk_migration_preserves_assignments_for_legacy_absent_workers() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); - merge_request::migrate(&conn).unwrap(); - - conn.execute_batch( - r#" -INSERT INTO workspaces ( - workspace_id, display_name, state, created_at, updated_at -) VALUES ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'); -INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, - workflow_state, workflow_state_explicit -) VALUES ( - 'workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', - 'planning', 1 -); -INSERT INTO worker_registry ( - workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at -) VALUES -( - 'workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001', - 'Worker A', 'normal', '2026-01-01', '2026-01-01' -), -( - 'workspace-a', 'runtime-old', '00000000-0000-7000-8000-000000000002', - 'Worker B', 'normal', '2026-01-01', '2026-01-01' -); -INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at -) VALUES -( - 'workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', - '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01' -), -( - 'workspace-a', 'ticket-a', 'assignment-b', 'runtime-old', - '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' -); -DELETE FROM worker_registry -WHERE workspace_id = 'workspace-a' - AND runtime_id = 'runtime-a' - AND worker_id = '00000000-0000-7000-8000-000000000001'; -UPDATE worker_registry -SET runtime_id = 'runtime-new' -WHERE workspace_id = 'workspace-a' - AND runtime_id = 'runtime-old' - AND worker_id = '00000000-0000-7000-8000-000000000002'; -"#, - ) - .unwrap(); - assign_explicit_test_workspace_owner(&conn); - - assert_eq!( - legacy_assignment_worker_tombstone_repairs(&conn) - .unwrap() - .len(), - 2 - ); - drop(conn); - - let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); - assert!( - plan.repairs.iter().any( - |repair| repair == "materialize 2 legacy Ticket assignment Worker tombstone(s)" - ), - "{:?}", - plan.repairs - ); - - let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); - assert!(!table_exists(&conn, "ticket_assignment_worker_tombstones").unwrap()); - apply_migrations_through(&conn, 39).unwrap(); - - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments \ - WHERE workspace_id = 'workspace-a' AND assignment_id = 'assignment-a'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 1 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_assignment_worker_tombstones \ - WHERE workspace_id = 'workspace-a' \ - AND runtime_id = 'runtime-a' \ - AND worker_id = '00000000-0000-7000-8000-000000000001'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 1 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM ticket_assignment_worker_tombstones \ - WHERE workspace_id = 'workspace-a' \ - AND runtime_id = 'runtime-old' \ - AND worker_id = '00000000-0000-7000-8000-000000000002'", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 1 - ); - validate_workspace_resource_references(&conn).unwrap(); - } - - #[test] - fn fresh_schema_matches_workspace_db_v0_boundaries() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations(&conn).unwrap(); - - let tables = table_names(&conn); - for expected in [ - "workspaces", - "objectives", - "objective_ticket_links", - "objective_resources", - "memory_staging_records", - "workspace_memory_documents", - "memory_staging_resolutions", - "repositories", - "artifacts", - "audit_events", - "worker_registry", - "worker_create_reservations", - "ticket_worker_assignments", - "ticket_current_worker_assignments", - "ticket_worker_assignment_events", - "workdir_registry", - "worker_workdir_links", - "accounts", - "users", - "passkey_credentials", - "auth_challenges", - "browser_sessions", - "api_tokens", - "device_login_flows", - ] { - assert!( - tables.contains(expected), - "missing expected v0 table {expected}" - ); - } - for forbidden in [ - "runs", - "hosts", - "workers", - "actors", - "validation_results", - "ci_results", - "tickets", - "ticket_events", - "ticket_relations", - "ticket_targets", - "ticket_target_paths", - "ticket_worker_links", - "ticket_notification_outbox", - "ticket_notification_deliveries", - "ticket_notification_cursors", - ] { - assert!( - !tables.contains(forbidden), - "fresh v0 schema must not create forbidden table {forbidden}" - ); - } - assert!( - !tables.iter().any(|table| table.starts_with("legacy_")), - "fresh v0 schema should not create legacy compatibility tables: {tables:?}" - ); - - assert_columns( - &conn, - "workspaces", - [ - "workspace_id", - "display_name", - "state", - "created_at", - "updated_at", - "owner_account_id", - ], - ); - assert_columns( - &conn, - "repositories", - [ - "workspace_id", - "repository_id", - "repository_key", - "kind", - "provider", - "uri", - "default_ref", - "created_at", - "updated_at", - "source_kind", - "source_uri", - "source_revision", - "source_fingerprint", - "observed_status", - "observed_at", - ], - ); - assert_columns( - &conn, - "worker_registry", - [ - "workspace_id", - "worker_id", - "runtime_id", - "display_name", - "profile", - "retention_state", - "transcript_ref", - "session_ref", - "summary_ref", - "diagnostics_ref", - "created_at", - "updated_at", - ], - ); - assert_columns( - &conn, - "workdir_registry", - [ - "workspace_id", - "workdir_id", - "runtime_id", - "repository_id", - "creation_selector", - "creation_ref", - "materialization_status", - "cleanliness", - "created_at", - "updated_at", - "current_selector", - "current_ref", - "creation_tree", - "current_tree", - "observed_at_epoch_seconds", - ], - ); - assert_columns( - &conn, - "artifacts", - [ - "workspace_id", - "artifact_id", - "kind", - "uri", - "media_type", - "sha256", - "size_bytes", - "summary", - "created_at", - "created_by_kind", - "created_by_key", - "created_by_display", - "created_by_source_kind", - "created_by_source_key", - "ticket_id", - "objective_id", - "event_id", - "worker_ref_kind", - "worker_ref_key", - "worker_display", - "repository_id", - "source_kind", - "source_revision", - ], - ); - - for table in ["workspaces", "repositories", "artifacts"] { - let columns = table_columns(&conn, table).unwrap(); - for forbidden_column in [ - "payload", - "payload_json", - "metadata", - "metadata_json", - "diagnostics_json", - "run_id", - "local_root", - "record_authority", - ] { - assert!( - !columns.iter().any(|column| column == forbidden_column), - "{table} must not contain obsolete/generic column {forbidden_column}" - ); - } - } - } - - #[tokio::test] - async fn upgrades_legacy_bootstrap_without_canonical_runs_table() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - conn.execute_batch(LEGACY_BOOTSTRAP_SQL).unwrap(); - conn.execute( - r#"INSERT INTO workspaces ( - workspace_id, display_name, local_root, record_authority, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, - params![ - "legacy-workspace", - "Legacy Workspace", - "/tmp/legacy-workspace", - "local_yoi_project_records", - "2026-01-01T00:00:00Z", - "2026-01-02T00:00:00Z", - ], - ) - .unwrap(); - conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (1, 'bootstrap workspace control plane')", - [], - ) - .unwrap(); - apply_migrations_through(&conn, 47).unwrap(); - assign_explicit_test_workspace_owner(&conn); - - let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 50); - - store - .with_conn(|conn| { - let tables = table_names(conn); - for expected in [ - "workspaces", - "repositories", - "artifacts", - "audit_events", - "workspace_memory_documents", - "memory_staging_records", - "memory_staging_resolutions", - "legacy_workspaces", - "legacy_repositories", - "legacy_runs", - "legacy_artifacts", - "legacy_ticket_projections", - "legacy_objective_projections", - ] { - assert!( - tables.contains(expected), - "missing {expected} after upgrade" - ); - } - for forbidden in [ - "runs", - "hosts", - "workers", - "actors", - "validation_results", - "tickets", - "ticket_events", - "ticket_relations", - "ticket_targets", - "ticket_target_paths", - "ticket_worker_links", - "ticket_notification_outbox", - "ticket_notification_deliveries", - "ticket_notification_cursors", - ] { - assert!( - !tables.contains(forbidden), - "upgraded schema must not retain forbidden canonical table {forbidden}" - ); - } - assert_columns( - conn, - "workspaces", - [ - "workspace_id", - "display_name", - "state", - "created_at", - "updated_at", - "owner_account_id", - ], - ); - let legacy_workspace_columns = table_columns(conn, "legacy_workspaces")?; - assert!( - legacy_workspace_columns - .iter() - .any(|column| column == "local_root") - ); - assert!( - legacy_workspace_columns - .iter() - .any(|column| column == "record_authority") - ); - let artifact_columns = table_columns(conn, "artifacts")?; - assert!(artifact_columns.iter().any(|column| column == "uri")); - assert!(!artifact_columns.iter().any(|column| column == "run_id")); - Ok(()) - }) - .unwrap(); - - assert_eq!( - store.get_workspace("legacy-workspace").await.unwrap(), - Some(WorkspaceRecord { - workspace_id: "legacy-workspace".to_string(), - owner_account_id: "owner-account".to_string(), - display_name: "Legacy Workspace".to_string(), - state: "active".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-02T00:00:00Z".to_string(), - }) - ); - - let new_record = WorkspaceRecord { - workspace_id: "new-workspace".to_string(), - owner_account_id: "owner-account".to_string(), - display_name: "New Workspace".to_string(), - state: "active".to_string(), - created_at: "2026-02-01T00:00:00Z".to_string(), - updated_at: "2026-02-01T00:00:00Z".to_string(), - }; - store.upsert_workspace(&new_record).await.unwrap(); - assert_eq!( - store.get_workspace("new-workspace").await.unwrap(), - Some(new_record) - ); - } - - #[test] - fn workdir_revision_migration_preserves_creation_evidence_and_leaves_observation_unknown() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - r#" -CREATE TABLE workdir_registry ( - workspace_id TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - selector TEXT, - resolved_commit TEXT, - materialization_status TEXT NOT NULL, - cleanliness TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, workdir_id) -); -INSERT INTO workdir_registry ( - workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, - materialization_status, cleanliness, created_at, updated_at -) VALUES ( - 'workspace', 'workdir', 'runtime', 'repository', 'develop', 'abcdef', - 'present', 'clean', '1', '2' -); -"#, - ) - .unwrap(); - - add_workdir_revision_observations(&conn).unwrap(); - - let values = conn - .query_row( - "SELECT creation_selector, creation_ref, current_selector, current_ref FROM workdir_registry", - [], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - }, - ) - .unwrap(); - assert_eq!( - values, - ( - Some("develop".to_string()), - Some("abcdef".to_string()), - None, - None, - ) - ); - } - - #[test] - fn combined_v20_migration_reconciles_ticket_branch_schema() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - r#" -CREATE TABLE workdir_registry ( - selector TEXT, - resolved_commit TEXT -); -CREATE TABLE ticket_assignment_operations ( - request_fingerprint TEXT -); -"#, - ) - .unwrap(); - - strengthen_ticket_assignment_lifecycle_reservations(&conn).unwrap(); - - assert!(column_exists(&conn, "workdir_registry", "creation_selector").unwrap()); - assert!(column_exists(&conn, "workdir_registry", "creation_ref").unwrap()); - assert!(column_exists(&conn, "workdir_registry", "current_selector").unwrap()); - assert!(column_exists(&conn, "workdir_registry", "current_ref").unwrap()); - assert!( - column_exists(&conn, "ticket_assignment_operations", "request_fingerprint").unwrap() - ); - } - #[tokio::test] async fn workspace_bootstrap_validates_key_and_rejects_duplicate_workspace() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -14682,339 +8286,4 @@ CREATE TABLE ticket_assignment_operations ( None ); } - - #[tokio::test] - async fn worker_mutation_source_jti_replay_guard_survives_reopen() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); - store - .upsert_trusted_runtime(&TrustedRuntimeRecord { - runtime_id: "runtime-a".to_string(), - workspace_id: None, - display_name: "Runtime A".to_string(), - base_url: "https://runtime.invalid".to_string(), - public_key: "public-key".to_string(), - created_at: "2026-08-11T00:00:00Z".to_string(), - updated_at: "2026-08-11T00:00:00Z".to_string(), - revoked_at: None, - }) - .unwrap(); - assert!( - store - .consume_worker_mutation_source_jti( - "runtime-a", - "proof-1", - 2_000, - 1_000, - "2026-08-11T00:00:00Z", - ) - .await - .unwrap() - ); - drop(store); - - let reopened = SqliteWorkspaceStore::open(&path).unwrap(); - assert!( - !reopened - .consume_worker_mutation_source_jti( - "runtime-a", - "proof-1", - 2_000, - 1_001, - "2026-08-11T00:00:01Z", - ) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn workspace_upsert_preserves_owner_identity() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); - for (account_id, handle) in [("owner-a", "owner-a"), ("owner-b", "owner-b")] { - store - .upsert_account(&AccountRecord { - account_id: account_id.to_string(), - kind: "user".to_string(), - handle: handle.to_string(), - display_name: handle.to_string(), - created_at: "2026-09-01T00:00:00Z".to_string(), - updated_at: "2026-09-01T00:00:00Z".to_string(), - }) - .unwrap(); - } - let mut workspace = WorkspaceRecord { - workspace_id: "workspace-owner-immutable".to_string(), - display_name: "Original".to_string(), - state: "active".to_string(), - owner_account_id: "owner-a".to_string(), - created_at: "2026-09-01T00:00:00Z".to_string(), - updated_at: "2026-09-01T00:00:00Z".to_string(), - }; - store.upsert_workspace(&workspace).await.unwrap(); - workspace.display_name = "Updated".to_string(); - store.upsert_workspace(&workspace).await.unwrap(); - workspace.owner_account_id = "owner-b".to_string(); - let error = store - .upsert_workspace(&workspace) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("owner is immutable"), "{error}"); - let persisted = store - .get_workspace("workspace-owner-immutable") - .await - .unwrap() - .unwrap(); - assert_eq!(persisted.owner_account_id, "owner-a"); - assert_eq!(persisted.display_name, "Updated"); - } - - #[test] - fn workspace_owner_migration_rejects_schema_drift_before_rebuild() { - let conn = workspace_owner_schema_47(); - conn.execute_batch( - r#" - PRAGMA foreign_keys = OFF; - PRAGMA legacy_alter_table = ON; - BEGIN EXCLUSIVE; - CREATE TABLE workspaces_drift ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT, - state TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - owner_account_id TEXT REFERENCES accounts(account_id) ON DELETE SET NULL - ); - DROP TABLE workspaces; - ALTER TABLE workspaces_drift RENAME TO workspaces; - COMMIT; - PRAGMA legacy_alter_table = OFF; - PRAGMA foreign_keys = ON; - "#, - ) - .unwrap(); - - let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema drift"), "{error}"); - assert_eq!(current_schema_version(&conn).unwrap(), 47); - assert_eq!( - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0)) - .unwrap(), - 1 - ); - } - - #[test] - fn workspace_owner_migration_fails_closed_for_ownerless_records() { - let conn = workspace_owner_schema_47(); - conn.execute( - "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at, owner_account_id) \ - VALUES ('workspace-ownerless', 'Ownerless', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL)", - [], - ) - .unwrap(); - - let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("explicit User Account owner"), "{error}"); - assert!(error.contains("workspace-ownerless"), "{error}"); - assert_eq!( - conn.query_row( - "SELECT MAX(version) FROM __yoi_schema_migrations", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 47 - ); - assert_eq!( - conn.query_row( - "SELECT COUNT(*) FROM workspaces WHERE owner_account_id IS NULL", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap(), - 1 - ); - let owner_not_null = conn - .prepare("PRAGMA table_info(workspaces)") - .unwrap() - .query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(3)?)) - }) - .unwrap() - .collect::>>() - .unwrap() - .into_iter() - .find_map(|(name, not_null)| (name == "owner_account_id").then_some(not_null)) - .unwrap(); - assert_eq!(owner_not_null, 0); - assert_eq!( - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0)) - .unwrap(), - 1 - ); - } - - #[test] - fn workspace_owner_migration_rejects_non_user_account_owner() { - let conn = workspace_owner_schema_47(); - conn.execute( - "INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) \ - VALUES ('organization-owner', 'organization', 'organization-owner', 'Organization Owner', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at, owner_account_id) \ - VALUES ('workspace-organization', 'Organization Workspace', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', 'organization-owner')", - [], - ) - .unwrap(); - - let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("explicit User Account owner"), "{error}"); - assert!(error.contains("workspace-organization"), "{error}"); - assert_eq!(current_schema_version(&conn).unwrap(), 47); - assert_eq!( - conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0)) - .unwrap(), - 1 - ); - } - - #[test] - fn workspace_owner_migration_requires_owner_and_restricts_account_deletion() { - let conn = workspace_owner_schema_47(); - conn.execute( - "INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) \ - VALUES ('owner-account', 'user', 'owner', 'Owner', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at, owner_account_id) \ - VALUES ('workspace-owned', 'Owned', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', 'owner-account')", - [], - ) - .unwrap(); - - apply_migrations(&conn).unwrap(); - - let owner_not_null = conn - .prepare("PRAGMA table_info(workspaces)") - .unwrap() - .query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(3)?)) - }) - .unwrap() - .collect::>>() - .unwrap() - .into_iter() - .find_map(|(name, not_null)| (name == "owner_account_id").then_some(not_null)) - .unwrap(); - assert_eq!(owner_not_null, 1); - let owner_delete_action = conn - .prepare("PRAGMA foreign_key_list(workspaces)") - .unwrap() - .query_map([], |row| { - Ok((row.get::<_, String>(3)?, row.get::<_, String>(6)?)) - }) - .unwrap() - .collect::>>() - .unwrap() - .into_iter() - .find_map(|(from, on_delete)| (from == "owner_account_id").then_some(on_delete)) - .unwrap(); - assert_eq!(owner_delete_action, "RESTRICT"); - assert!( - conn.execute( - "DELETE FROM accounts WHERE account_id = 'owner-account'", - [] - ) - .is_err() - ); - assert_eq!( - conn.query_row("PRAGMA foreign_key_check", [], |_| Ok(())) - .optional() - .unwrap(), - None - ); - } - - fn workspace_owner_schema_47() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 47).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 47); - conn - } - - fn table_names(conn: &Connection) -> BTreeSet { - let mut stmt = conn - .prepare( - "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", - ) - .unwrap(); - let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); - rows.collect::>>().unwrap() - } - - fn assert_columns(conn: &Connection, table: &str, expected: [&str; N]) { - let columns = table_columns(conn, table).unwrap(); - let expected = expected.map(str::to_string).to_vec(); - assert_eq!(columns, expected, "unexpected columns for {table}"); - } - - const LEGACY_BOOTSTRAP_SQL: &str = r#" -CREATE TABLE workspaces ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - local_root TEXT NOT NULL, - record_authority TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE TABLE repositories ( - repository_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - local_root TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL -); -CREATE TABLE ticket_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id) -); -CREATE TABLE objective_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id) -); -CREATE TABLE runs ( - run_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - subject_kind TEXT NOT NULL, - subject_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE TABLE artifacts ( - artifact_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL, - path TEXT NOT NULL, - content_type TEXT, - created_at TEXT NOT NULL -); -"#; } diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 67902829..7a5633ee 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -91,44 +91,6 @@ pub struct WorkdirRemovalGuard { pub detail: &'static str, } -pub(crate) fn create_workdir_removal_operations(conn: &Connection) -> Result<()> { - conn.execute_batch( - r#" -CREATE TABLE workdir_removal_operations ( - workspace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - request_fingerprint TEXT NOT NULL, - workdir_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - materialization_fingerprint TEXT NOT NULL, - source_actor TEXT NOT NULL, - reason TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), - attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), - retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), - disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), - failure_category TEXT, - attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), - attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE -); -CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending - ON workdir_removal_operations(workspace_id, workdir_id) - WHERE state = 'pending'; -CREATE INDEX idx_workdir_removal_operations_recovery - ON workdir_removal_operations(workspace_id, state, retryable, updated_at); -CREATE INDEX idx_workdir_removal_operations_workdir - ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC); -"#, - )?; - Ok(()) -} - pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> String { let bytes = serde_json::to_vec(&serde_json::json!([ record.workspace_id, diff --git a/docs/README.md b/docs/README.md index 954e6cdc..6184056c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 16. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 17. [`development/validation.md`](development/validation.md) — how to check changes. -18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. +18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — the canonical SQLite baseline, compatibility fence, and manual dogfooding-data repair procedure. 19. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 ## What belongs here diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index d770755c..5acb2077 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -1,43 +1,41 @@ -# Workspace database schema migration runbook +# Workspace database schema baseline -The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority. +The Workspace Server owns one control-plane SQLite database. New databases are created directly from the current canonical schema; the repository does not retain an executable chain of historical Workspace schema migrations. -## Before deployment +Domain components such as Ticket and Merge Request contribute their current tables to the same database, but they do not create a second Workspace authority. -1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration. -2. Record the current binary revision and database schema version. -3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure. -4. Run the read-only plan with the new binary: +## Compatibility boundary - ```sh - yoi-server migrate --dry-run --database +The Server accepts only the current canonical schema generation. Its `__yoi_schema_migrations` ledger must contain exactly one row naming that baseline. A database with an older, newer, or multi-generation Workspace migration history is rejected at startup. + +This is intentional while Yoi has only the dogfooding deployment. Schema changes may replace the baseline rather than adding permanent compatibility code. Existing dogfooding data must be migrated manually and atomically before starting the new binary. + +## Updating the dogfooding database + +1. Stop every Server and Runtime process that can write the affected SQLite or Runtime stores. +2. Record the current binary revision and schema generation. +3. Take a SQLite-safe backup of `server.db` and a filesystem backup of any Runtime stores whose persisted contracts change. +4. Apply the data and schema repair explicitly. Keep Workspace SQL data and Runtime filesystem data as separate authorities; changing one does not repair the other. +5. Replace historical migration-ledger rows with the single marker expected by the current baseline. +6. Validate before startup: + + ```sql + PRAGMA foreign_key_check; + PRAGMA integrity_check; ``` - The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying. +7. Start exactly one Server generation and verify the affected API contracts. -## Applying +There is no in-place down migration and no automatic upgrade from an old baseline. Rollback means restoring both the prior binary and the complete matching database and Runtime-store backups. -Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: +## Creating a new baseline -- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity; -- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; -- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error; -- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist; -- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and -- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. +A baseline change must include: -After startup, verify: +- canonical DDL that creates a fresh database directly at the new generation; +- current-schema verification for Workspace, Ticket, and Merge Request tables; +- tests proving a fresh database records only the canonical baseline marker; +- an explicit, separately reviewed repair procedure for the current dogfooding data; +- removal of obsolete migration functions, fixtures, commands, and documentation. -```sql -SELECT MAX(version) FROM __yoi_schema_migrations; -PRAGMA foreign_key_check; -PRAGMA integrity_check; -``` - -The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`. - -## Failure and rollback - -There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying. - -Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database. +Do not put temporary legacy interpretation into normal request or projection paths. If persisted Runtime data also changes identity or shape, repair that Runtime authority explicitly instead of teaching steady-state Workspace APIs to accept both contracts indefinitely.