diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 9c810dcc..3661b20e 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -137,8 +137,7 @@ pub struct BackendWorkerCapabilitySummary { pub struct BackendWorkerSummary { pub runtime_id: String, pub worker_id: String, - #[serde(default)] - pub human_key: Option, + pub resource_key: String, pub host_id: String, #[serde(default)] pub display_name: String, @@ -651,6 +650,7 @@ mod tests { let payload = serde_json::json!({ "runtime_id": "arcadia", "worker_id": "worker-opaque-64", + "resource_key": "W-64", "host_id": "host", "display_name": "Coder", "label": "Coder", diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index 053f7810..150109f7 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -551,6 +551,10 @@ pub struct SubscriptionWorker { /// Runtime producers leave this unset because the connection identifies the Runtime. #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_id: Option, + /// Workspace-scoped canonical resource key. Runtime producers leave this unset; + /// Workspace-facing projections must populate it before publishing the Worker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_key: Option, /// Producer-owned monotonic revision for this Worker subject. pub subject_revision: u64, pub state: SubscriptionWorkerState, @@ -574,6 +578,9 @@ impl SubscriptionWorker { if let Some(runtime_id) = &self.runtime_id { validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?; } + if let Some(resource_key) = &self.resource_key { + validate_identifier("resource_key", resource_key, MAX_RESOURCE_ID_BYTES)?; + } if let Some(repository_id) = &self.repository_id { validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?; } @@ -796,6 +803,7 @@ mod tests { SubscriptionWorker { worker_id: worker_id(value), runtime_id: None, + resource_key: None, subject_revision: 0, state: SubscriptionWorkerState::Idle, has_running_internal_workers: false, diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index d6b0f964..4ea1d2c6 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -27,7 +27,9 @@ mod sqlite_schema; pub mod tool; pub use sqlite_schema::{ - LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_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, }; const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"]; @@ -124,7 +126,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(7)?; Ok(TicketSummary { id: row.get(0)?, - human_key: None, + resource_key: None, slug: row.get(1)?, title: row.get(2)?, status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), @@ -928,7 +930,7 @@ impl TicketListQuery { pub struct TicketRef { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub human_key: Option, + pub resource_key: Option, pub slug: String, pub status: TicketStatus, } @@ -1544,7 +1546,7 @@ pub struct OrchestrationPlanRecord { pub struct TicketMeta { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub human_key: Option, + pub resource_key: Option, pub slug: String, pub title: String, pub status: ExtensibleTicketStatus, @@ -1569,7 +1571,7 @@ pub struct TicketMeta { pub struct TicketSummary { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub human_key: Option, + pub resource_key: Option, pub slug: String, pub title: String, pub status: ExtensibleTicketStatus, @@ -2677,7 +2679,7 @@ impl SqliteTicketBackend { .collect::, _>>() .map_err(sqlite_err)?; for summary in &mut summaries { - summary.human_key = Self::human_key_for(conn, &self.workspace_id, &summary.id)?; + summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?; } let has_more = summaries.len() > query.limit; summaries.truncate(query.limit); @@ -2762,7 +2764,7 @@ impl SqliteTicketBackend { updated_at, ) = row.map_err(sqlite_err)?; summaries.push(TicketSummary { - human_key: Self::human_key_for(conn, &self.workspace_id, &id)?, + resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?, id, slug, title, @@ -2944,8 +2946,8 @@ impl SqliteTicketBackend { "SELECT ticket_id FROM typed_tickets WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2) UNION - SELECT resource_id FROM workspace_resource_human_keys - WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND human_key = ?2 + SELECT resource_id FROM workspace_resource_keys + WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_key = ?2 ORDER BY 1", ) .map_err(sqlite_err)?; @@ -2967,13 +2969,13 @@ impl SqliteTicketBackend { } } - fn human_key_for( + fn resource_key_for( conn: &Connection, workspace_id: &str, ticket_id: &str, ) -> Result> { conn.query_row( - "SELECT human_key FROM workspace_resource_human_keys + "SELECT resource_key FROM workspace_resource_keys WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_id = ?2", params![workspace_id, ticket_id], |row| row.get(0), @@ -2982,44 +2984,50 @@ impl SqliteTicketBackend { .map_err(sqlite_err) } - fn allocate_human_key( + fn allocate_resource_key( conn: &Connection, workspace_id: &str, ticket_id: &str, allocated_at: &str, ) -> Result { - if let Some(existing) = Self::human_key_for(conn, workspace_id, ticket_id)? { + if let Some(existing) = Self::resource_key_for(conn, workspace_id, ticket_id)? { return Ok(existing); } conn.execute( - "INSERT OR IGNORE INTO workspace_resource_human_key_counters + "INSERT OR IGNORE INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence) VALUES (?1, 'ticket', 1)", params![workspace_id], ) .map_err(sqlite_err)?; let sequence: i64 = conn .query_row( - "SELECT next_sequence FROM workspace_resource_human_key_counters + "SELECT next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 AND resource_kind = 'ticket'", params![workspace_id], |row| row.get(0), ) .map_err(sqlite_err)?; conn.execute( - "UPDATE workspace_resource_human_key_counters SET next_sequence = ?3 + "UPDATE workspace_resource_key_counters SET next_sequence = ?3 WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND next_sequence = ?2", params![workspace_id, sequence, sequence + 1], ) .map_err(sqlite_err)?; - let human_key = format!("T-{sequence}"); + let resource_key = format!("T-{sequence}"); conn.execute( - "INSERT INTO workspace_resource_human_keys - (workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at) + "INSERT INTO workspace_resource_keys + (workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at) VALUES (?1, 'ticket', ?2, ?3, ?4, ?5)", - params![workspace_id, ticket_id, sequence, human_key, allocated_at], + params![ + workspace_id, + ticket_id, + sequence, + resource_key, + allocated_at + ], ) .map_err(sqlite_err)?; - Ok(human_key) + Ok(resource_key) } fn ticket_exists(&self, conn: &Connection, id: &str) -> Result { @@ -3165,7 +3173,7 @@ impl SqliteTicketBackend { let state_raw: String = row.get(12)?; Ok(TicketMeta { id: row.get(0)?, - human_key: None, + resource_key: None, slug: row.get(1)?, title: row.get(2)?, status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), @@ -3193,7 +3201,7 @@ impl SqliteTicketBackend { self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst); let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; - meta.human_key = Self::human_key_for(conn, &self.workspace_id, ticket_id)?; + meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?; meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; meta.risk_flags = self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?; @@ -3365,7 +3373,7 @@ impl SqliteTicketBackend { let mut summaries = Vec::new(); for row in rows { let mut meta = row.map_err(sqlite_err)?; - meta.human_key = Self::human_key_for(conn, &self.workspace_id, &meta.id)?; + meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, &meta.id)?; if !filter.matches_state(meta.workflow_state) { continue; } @@ -3515,7 +3523,7 @@ impl TicketBackend for SqliteTicketBackend { }; let meta = TicketMeta { id: id.clone(), - human_key: None, + resource_key: None, slug: input.slug.clone().unwrap_or_else(|| id.clone()), title: input.title, status, @@ -3567,11 +3575,11 @@ impl TicketBackend for SqliteTicketBackend { relations: TicketRelationView::default(), resolution: None, }; - let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?; + let resource_key = Self::allocate_resource_key(conn, &self.workspace_id, &id, &now)?; self.insert_ticket(conn, &ticket)?; Ok(TicketRef { id: id.clone(), - human_key: Some(human_key), + resource_key: Some(resource_key), slug: id, status: TicketStatus::Open, }) @@ -4218,7 +4226,7 @@ impl TicketBackend for LocalTicketBackend { atomic_write(&dir.join("thread.md"), thread.as_bytes())?; Ok(TicketRef { id: id.clone(), - human_key: None, + resource_key: None, slug: id, status: TicketStatus::Open, }) @@ -5256,7 +5264,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { }; TicketMeta { id: id.clone(), - human_key: None, + resource_key: None, slug: id, title: frontmatter.title.unwrap_or_default(), status, @@ -5281,7 +5289,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { TicketSummary { id: meta.id, - human_key: meta.human_key, + resource_key: meta.resource_key, slug: meta.slug, title: meta.title, status: meta.status, @@ -6890,7 +6898,7 @@ mod tests { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { TicketSummary { id: "000TEST".to_string(), - human_key: Some("T-1".to_string()), + resource_key: Some("T-1".to_string()), slug: "000TEST".to_string(), title: "Test Ticket".to_string(), status: ExtensibleTicketStatus::Open, @@ -7296,14 +7304,14 @@ state: planning } #[test] - fn sqlite_human_keys_are_workspace_scoped_monotonic_and_resolvable() { + fn sqlite_resource_keys_are_workspace_scoped_monotonic_and_resolvable() { let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("workspace.db"); let backend = SqliteTicketBackend::open(&db_path, "workspace-a").unwrap(); let first = backend.create(NewTicket::new("First")).unwrap(); let second = backend.create(NewTicket::new("Second")).unwrap(); - assert_eq!(first.human_key.as_deref(), Some("T-1")); - assert_eq!(second.human_key.as_deref(), Some("T-2")); + assert_eq!(first.resource_key.as_deref(), Some("T-1")); + assert_eq!(second.resource_key.as_deref(), Some("T-2")); assert_eq!(backend.show("T-1".into()).unwrap().meta.id, first.id); let projection = backend.list_workspace_projection(100).unwrap(); let projected_second = projection @@ -7311,16 +7319,19 @@ state: planning .iter() .find(|item| item.summary.id == second.id) .unwrap(); - assert_eq!(projected_second.summary.human_key.as_deref(), Some("T-2")); + assert_eq!( + projected_second.summary.resource_key.as_deref(), + Some("T-2") + ); let other = SqliteTicketBackend::open(&db_path, "workspace-b").unwrap(); let other_first = other.create(NewTicket::new("Other")).unwrap(); - assert_eq!(other_first.human_key.as_deref(), Some("T-1")); + assert_eq!(other_first.resource_key.as_deref(), Some("T-1")); assert_eq!(other.show("T-1".into()).unwrap().meta.id, other_first.id); } #[test] - fn sqlite_human_key_allocation_is_concurrency_safe() { + fn sqlite_resource_key_allocation_is_concurrency_safe() { let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("workspace.db"); SqliteTicketBackend::open(&db_path, "workspace-a").unwrap(); @@ -7335,7 +7346,7 @@ state: planning backend .create(NewTicket::new(format!("Ticket {index}"))) .unwrap() - .human_key + .resource_key .unwrap() }) }) diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 5dca7fc1..6ca773ee 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 = 5; +pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; #[derive(Clone, Copy)] struct Migration { @@ -42,6 +42,11 @@ const MIGRATIONS: &[Migration] = &[ name: "add_workspace_human_keys", apply: add_workspace_human_keys, }, + Migration { + version: 6, + name: "rename_workspace_resource_keys", + apply: rename_workspace_resource_keys, + }, ]; #[derive(Clone, Copy)] @@ -71,6 +76,22 @@ const OWNED_TABLES: &[&str] = &[ "typed_ticket_relations", "typed_ticket_orchestration_plans", "typed_ticket_artifacts", + "workspace_resource_keys", +]; + +const RESOURCE_KEY_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("resource_kind", "TEXT", true, 2), + column("resource_id", "TEXT", true, 3), + column("sequence", "INTEGER", true, 0), + column("resource_key", "TEXT", true, 0), + column("allocated_at", "TEXT", true, 0), +]; + +const RESOURCE_KEY_COUNTER_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("resource_kind", "TEXT", true, 2), + column("next_sequence", "INTEGER", true, 0), ]; const MIGRATION_COLUMNS: &[ExpectedColumn] = &[ @@ -243,6 +264,24 @@ const fn column( /// 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)?; @@ -265,7 +304,20 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { let applied = load_applied_migrations(connection)?; validate_applied_migrations(&applied)?; - for migration in MIGRATIONS { + 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; } @@ -283,7 +335,22 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err)?; } - verify_sqlite_ticket_schema(connection) + 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(()) + } })(); match result { @@ -295,6 +362,47 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } +/// 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(); @@ -368,6 +476,49 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + collect_column_diagnostics( + connection, + "workspace_resource_keys", + RESOURCE_KEY_COLUMNS, + &mut diagnostics, + ); + collect_column_diagnostics( + connection, + "workspace_resource_key_counters", + RESOURCE_KEY_COUNTER_COLUMNS, + &mut diagnostics, + ); + collect_index_diagnostics( + connection, + "workspace_resource_keys", + None, + true, + &["workspace_id", "resource_kind", "sequence"], + &mut diagnostics, + ); + collect_index_diagnostics( + connection, + "workspace_resource_keys", + None, + true, + &["workspace_id", "resource_key"], + &mut diagnostics, + ); + collect_index_diagnostics( + connection, + "workspace_resource_keys", + Some("idx_workspace_resource_keys_reverse"), + false, + &["workspace_id", "resource_kind", "resource_key"], + &mut diagnostics, + ); + for legacy_table in [ + "workspace_resource_human_keys", + "workspace_resource_human_key_counters", + ] { + collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics); + } + for table in OWNED_TABLES { collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -591,6 +742,21 @@ fn add_workspace_human_keys(connection: &Connection) -> Result<()> { .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, @@ -719,6 +885,106 @@ fn verify_table( } } +fn table_exists(connection: &Connection, table: &str) -> Result { + connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |_| Ok(()), + ) + .optional() + .map(|value| value.is_some()) + .map_err(sqlite_err) +} + +fn collect_absent_table_diagnostic( + connection: &Connection, + table: &str, + diagnostics: &mut Vec, +) { + match table_exists(connection, table) { + Ok(false) => {} + Ok(true) => diagnostics.push(format!("legacy table `{table}` is still present")), + Err(error) => { + diagnostics.push(format!("failed to inspect legacy table `{table}`: {error}")) + } + } +} + +fn collect_index_diagnostics( + connection: &Connection, + table: &str, + expected_name: Option<&str>, + expected_unique: bool, + expected_columns: &[&str], + diagnostics: &mut Vec, +) { + let sql = format!("PRAGMA index_list({table})"); + let mut statement = match connection.prepare(&sql) { + Ok(statement) => statement, + Err(error) => { + diagnostics.push(format!("failed to inspect indexes for `{table}`: {error}")); + return; + } + }; + let rows = match statement.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0)) + }) { + Ok(rows) => rows, + Err(error) => { + diagnostics.push(format!("failed to read indexes for `{table}`: {error}")); + return; + } + }; + let indexes = match rows.collect::, _>>() { + Ok(indexes) => indexes, + Err(error) => { + diagnostics.push(format!("failed to decode indexes for `{table}`: {error}")); + return; + } + }; + for (name, unique) in indexes { + if expected_name.is_some_and(|expected| expected != name) || unique != expected_unique { + continue; + } + let sql = format!("PRAGMA index_info({name})"); + let mut statement = match connection.prepare(&sql) { + Ok(statement) => statement, + Err(error) => { + diagnostics.push(format!("failed to inspect index `{name}`: {error}")); + return; + } + }; + let rows = match statement.query_map([], |row| row.get::<_, String>(2)) { + Ok(rows) => rows, + Err(error) => { + diagnostics.push(format!("failed to read index `{name}`: {error}")); + return; + } + }; + match rows.collect::, _>>() { + Ok(columns) if columns == expected_columns => return, + Ok(_) => {} + Err(error) => { + diagnostics.push(format!("failed to decode index `{name}`: {error}")); + return; + } + } + } + let identity = expected_name + .map(|name| format!("named `{name}`")) + .unwrap_or_else(|| "unnamed".to_string()); + diagnostics.push(format!( + "table `{table}` is missing {identity} {} index on ({})", + if expected_unique { + "unique" + } else { + "non-unique" + }, + expected_columns.join(", ") + )); +} + fn collect_table_diagnostics( connection: &Connection, table: &str, @@ -923,10 +1189,10 @@ mod tests { verify_sqlite_ticket_schema(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap(); - assert_eq!(versions.len(), 5); + assert_eq!(versions.len(), 6); assert_eq!( versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), - Some(&"add_workspace_human_keys".to_string()) + Some(&"rename_workspace_resource_keys".to_string()) ); } @@ -1014,14 +1280,11 @@ mod tests { } #[test] - fn v5_backfills_ticket_keys_by_creation_order_and_advances_counter() { + fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() { let connection = Connection::open_in_memory().unwrap(); - migrate_sqlite_ticket_schema(&connection).unwrap(); + migrate_sqlite_ticket_schema_through(&connection, 4).unwrap(); connection.execute_batch( - "DROP TABLE workspace_resource_human_key_counters; - DROP TABLE workspace_resource_human_keys; - DELETE FROM ticket_schema_migrations WHERE version = 5; - INSERT INTO typed_tickets ( + "INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, created_at, updated_at ) VALUES @@ -1029,12 +1292,12 @@ mod tests { ('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(&connection).unwrap(); - let keys = connection + 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", + WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket' + ORDER BY sequence", ) .unwrap() .query_map([], |row| { @@ -1044,7 +1307,7 @@ mod tests { .collect::, _>>() .unwrap(); assert_eq!( - keys, + legacy_keys, vec![ ("earlier".into(), "T-1".into()), ("later".into(), "T-2".into()) @@ -1053,12 +1316,56 @@ mod tests { let next: i64 = connection .query_row( "SELECT next_sequence FROM workspace_resource_human_key_counters - WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'", + 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] @@ -1119,7 +1426,39 @@ mod tests { .to_string() .contains("unsupported Ticket schema migration version 99") ); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7); + } + + #[test] + fn verifier_rejects_resource_key_schema_drift() { + for (drift, expected) in [ + ( + "DROP TABLE workspace_resource_key_counters", + "workspace_resource_key_counters", + ), + ( + "ALTER TABLE workspace_resource_keys RENAME COLUMN resource_key TO human_key", + "missing column \"resource_key\"", + ), + ( + "DROP INDEX idx_workspace_resource_keys_reverse", + "idx_workspace_resource_keys_reverse", + ), + ( + "CREATE TABLE workspace_resource_human_keys (value TEXT)", + "legacy table `workspace_resource_human_keys` is still present", + ), + ] { + let connection = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + connection.execute_batch(drift).unwrap(); + + let error = verify_sqlite_ticket_schema(&connection).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected {expected:?} after {drift:?}, got {error}" + ); + } } #[test] @@ -1220,7 +1559,11 @@ mod tests { 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("DELETE FROM ticket_schema_migrations WHERE version>=3", []) + .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(); @@ -1259,6 +1602,6 @@ mod tests { let connection = Connection::open(database).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap(); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6); } } diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 5ff2d925..2683336f 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -348,17 +348,6 @@ fn state_style(state: &str) -> Style { } } -fn short_worker_id(worker: &BackendWorkerSummary) -> String { - if let Some(human_key) = worker.human_key.as_ref() { - return human_key.clone(); - } - format!( - "{}:{}", - short_text(&worker.runtime_id), - short_text(&worker.worker_id) - ) -} - fn short_text(text: &str) -> String { const MAX: usize = 24; let mut chars = text.chars(); @@ -370,6 +359,10 @@ fn short_text(text: &str) -> String { } } +fn short_worker_id(worker: &BackendWorkerSummary) -> String { + worker.resource_key.clone() +} + fn working_directory_text(worker: &BackendWorkerSummary) -> String { let Some(wd) = worker.working_directory.as_ref() else { return "wd:—".to_string(); @@ -393,7 +386,7 @@ mod tests { BackendWorkerSummary { runtime_id: runtime_id.to_string(), worker_id: worker_id.to_string(), - human_key: None, + resource_key: "W-1".to_string(), host_id: "host".to_string(), label: "label".to_string(), display_name: "label".to_string(), @@ -429,7 +422,7 @@ mod tests { .into_iter() .map(|span| span.content) .collect::(); - assert!(text.starts_with("▶ runtime-a:worker-b")); + assert!(text.starts_with("▶ W-1")); assert!(text.contains("[running]")); assert!(text.contains("profile:default")); assert!(text.contains("wd:—")); diff --git a/crates/tui/src/dashboard/render.rs b/crates/tui/src/dashboard/render.rs index 7236b565..f2a5f78e 100644 --- a/crates/tui/src/dashboard/render.rs +++ b/crates/tui/src/dashboard/render.rs @@ -537,9 +537,9 @@ pub(super) fn panel_ticket_reference(row: &PanelRow) -> String { .as_ref() .map(|ticket| { ticket - .human_key + .resource_key .clone() - .unwrap_or_else(|| ticket.id.clone()) + .unwrap_or_else(|| "resource key unavailable".to_string()) }) .unwrap_or_else(|| match &row.key { PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index 353b56ce..d6381100 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -1737,7 +1737,13 @@ fn panel_ticket_rows_render_state_title_then_detail_line() { let state_start = 2; let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; let row_id = row.ticket.as_ref().unwrap().id.as_str(); - let human_key = row.ticket.as_ref().unwrap().human_key.as_deref().unwrap(); + let resource_key = row + .ticket + .as_ref() + .unwrap() + .resource_key + .as_deref() + .unwrap(); assert!(title_line.starts_with("▶ ")); assert!(detail_line.starts_with("│ meta ")); @@ -1747,7 +1753,7 @@ fn panel_ticket_rows_render_state_title_then_detail_line() { display_column(&title_line, "Workspace Dashboard composer targets"), title_start ); - assert!(detail_line.contains(human_key)); + assert!(detail_line.contains(resource_key)); assert!(detail_line.contains("Gate: clear")); assert!(detail_line.contains("Action: Wait")); } @@ -3266,7 +3272,7 @@ fn panel_test_ticket_row( ) -> PanelRow { let ticket = crate::workspace_panel::TicketPanelEntry { id: id.to_string(), - human_key: Some("T-1".to_string()), + resource_key: Some("T-1".to_string()), title: title.to_string(), priority: "P2".to_string(), workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index dd1bc10a..92a84f6f 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -253,7 +253,7 @@ impl NextUserAction { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketPanelEntry { pub(crate) id: String, - pub(crate) human_key: Option, + pub(crate) resource_key: Option, pub(crate) title: String, pub(crate) priority: String, pub(crate) workflow_state: TicketWorkflowState, @@ -1064,7 +1064,7 @@ pub(crate) fn build_current_ticket_row( fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { TicketSummary { id: meta.id.clone(), - human_key: meta.human_key.clone(), + resource_key: meta.resource_key.clone(), slug: meta.slug.clone(), title: meta.title.clone(), status: meta.status.clone(), @@ -1240,7 +1240,7 @@ fn ticket_row( let next_action = projection.next_action.map(next_user_action_from_workspace); let entry = TicketPanelEntry { id: summary.id.clone(), - human_key: summary.human_key.clone(), + resource_key: summary.resource_key.clone(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 7447f329..7750ecbc 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2262,6 +2262,7 @@ impl RuntimeState { Ok(SubscriptionWorker { worker_id, runtime_id: None, + resource_key: None, subject_revision: self .worker_subject_revisions .get(&worker.worker_id) diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 60acbf5e..cf873514 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -1781,7 +1781,7 @@ provider = "github" assert!(request.contains("\"title\":\"HTTP ticket\"")); let response_body = serde_json::to_string(&TicketRef { id: "01TEST".to_string(), - human_key: None, + resource_key: None, slug: "http-ticket".to_string(), status: ticket::TicketStatus::Open, }) diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 2f8fc116..0ed2aaf0 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -228,10 +228,10 @@ impl SqliteWorkspaceAuthority { self } - fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result { + fn resource_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result { self.store - .resource_human_key(&self.workspace_id, kind, resource_id)? - .ok_or_else(|| Error::Store(format!("missing human key for {resource_id}"))) + .resource_key(&self.workspace_id, kind, resource_id)? + .ok_or_else(|| Error::Store(format!("missing resource key for {resource_id}"))) } fn objective_record(&self, reference: &str) -> Result { @@ -262,7 +262,7 @@ impl SqliteWorkspaceAuthority { .filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id)) .map(|ticket| ObjectiveLinkedTicketSummary { id: ticket.id, - human_key: ticket.human_key, + resource_key: ticket.resource_key, title: ticket.title, state: ticket.state, }) @@ -304,7 +304,8 @@ impl SqliteWorkspaceAuthority { .unwrap_or("none") ); Ok(ObjectiveDetail { - human_key: self.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, + resource_key: self + .resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -746,8 +747,8 @@ impl SqliteWorkspaceAuthority { .into_iter() .map(|objective| { Ok::<_, Error>(ObjectiveLinkSummary { - human_key: self - .human_key(WorkspaceResourceKind::Objective, &objective.objective_id)?, + resource_key: self + .resource_key(WorkspaceResourceKind::Objective, &objective.objective_id)?, id: objective.objective_id, title: objective.title, state: objective.state, @@ -765,7 +766,7 @@ impl SqliteWorkspaceAuthority { .store .get_current_ticket_worker_assignment(&self.workspace_id, id)? .map(|assignment| { - let worker_human_key = self.store.resource_human_key( + let worker_resource_key = self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Worker, &assignment.worker.worker_id, @@ -774,7 +775,7 @@ impl SqliteWorkspaceAuthority { assignment_id: assignment.assignment_id, runtime_id: assignment.worker.runtime_id, worker_id: assignment.worker.worker_id, - worker_human_key, + worker_resource_key, }) }) .transpose()?; @@ -802,33 +803,33 @@ impl SqliteWorkspaceAuthority { .and_then(|event| event.attributes.get("event_id").cloned()) .or_else(|| ticket.meta.updated_at.clone()) .unwrap_or_else(|| format!("{}:0", ticket.meta.id)); - let human_key = ticket + let resource_key = ticket .meta - .human_key + .resource_key .clone() - .or(self.store.resource_human_key( + .or(self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Ticket, &ticket.meta.id, )?) - .ok_or_else(|| Error::Store(format!("missing human key for {}", ticket.meta.id)))?; + .ok_or_else(|| Error::Store(format!("missing resource key for {}", ticket.meta.id)))?; let mut relations: TicketRelationView = ticket.relations.into(); for relation in &mut relations.outgoing { - relation.target_human_key = self.store.resource_human_key( + relation.target_resource_key = self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Ticket, &relation.target, )?; } for relation in &mut relations.incoming { - relation.source_human_key = self.store.resource_human_key( + relation.source_resource_key = self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Ticket, &relation.source_ticket, )?; } for blocker in &mut relations.blockers { - blocker.blocking_human_key = self.store.resource_human_key( + blocker.blocking_resource_key = self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Ticket, &blocker.blocking_ticket, @@ -836,7 +837,7 @@ impl SqliteWorkspaceAuthority { } Ok(TicketDetail { id: ticket.meta.id, - human_key, + resource_key, title: ticket.meta.title, state: ticket.meta.workflow_state.as_str().to_string(), readiness: ticket.meta.readiness, @@ -892,11 +893,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .map(|item| { let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - let human_key = item.summary.human_key.clone().ok_or_else(|| { - Error::Store(format!("missing human key for {}", item.summary.id)) + let resource_key = item.summary.resource_key.clone().ok_or_else(|| { + Error::Store(format!("missing resource key for {}", item.summary.id)) })?; Ok::<_, Error>(TicketSummary { - human_key, + resource_key, id: item.summary.id, title: item.summary.title, state: item.summary.workflow_state.as_str().to_string(), @@ -1063,8 +1064,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { .map(|link| link.ticket_id) .collect::>(); items.push(ObjectiveSummary { - human_key: self - .human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, + resource_key: self + .resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -1110,8 +1111,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { .collect::>(); let body_md = record.body_md.clone(); let objective = ObjectiveSummary { - human_key: self - .human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, + resource_key: self + .resource_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -2137,7 +2138,7 @@ fn ticket_query_item( } TicketQueryItem { id: summary.id, - human_key: summary.human_key, + resource_key: summary.resource_key, title: summary.title, state: summary.state, readiness: detail.readiness.clone(), @@ -2288,6 +2289,7 @@ fn objective_query_item( } ObjectiveQueryItem { id: objective.id, + resource_key: objective.resource_key, title: objective.title, state: objective.state, created_at: objective.created_at, @@ -2480,7 +2482,7 @@ fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> Memor fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result { let summary = ticket::TicketSummary { id: ticket.meta.id.clone(), - human_key: ticket.meta.human_key.clone(), + resource_key: ticket.meta.resource_key.clone(), slug: ticket.meta.slug.clone(), title: ticket.meta.title.clone(), status: ticket.meta.status.clone(), @@ -2502,13 +2504,13 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result { let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - let human_key = item + let resource_key = item .summary - .human_key + .resource_key .clone() - .ok_or_else(|| Error::Store(format!("missing human key for {}", item.summary.id)))?; + .ok_or_else(|| Error::Store(format!("missing resource key for {}", item.summary.id)))?; Ok(TicketSummary { - human_key, + resource_key, id: item.summary.id, title: item.summary.title, state: item.summary.workflow_state.as_str().to_string(), @@ -2865,13 +2867,13 @@ mod tests { .unwrap() .execute_batch( r#" -INSERT INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +INSERT INTO workspace_resource_keys ( + workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), ('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'), ('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z'); -INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +INSERT INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence) VALUES ('workspace-test', 'ticket', 4); "#, ) @@ -2965,7 +2967,7 @@ VALUES ('workspace-test', 'ticket', 4); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); assert_eq!(tickets.items[0].workspace_action_priority, "background"); - let ticket_by_key = authority.ticket(&tickets.items[0].human_key).unwrap(); + let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap(); assert_eq!(ticket_by_key.id, tickets.items[0].id); let ticket = authority.ticket("00000000001J2").unwrap(); @@ -3138,12 +3140,14 @@ VALUES ('workspace-test', 'ticket', 4); assert_eq!(objectives.items.len(), 1); assert_eq!(objectives.items[0].id, "00000000001J3"); assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]); - let objective_by_key = authority.objective(&objectives.items[0].human_key).unwrap(); + let objective_by_key = authority + .objective(&objectives.items[0].resource_key) + .unwrap(); assert_eq!(objective_by_key.id, objectives.items[0].id); assert_eq!( authority .show_objective( - &objectives.items[0].human_key, + &objectives.items[0].resource_key, ObjectiveShowRequest::default(), ) .unwrap() @@ -3241,12 +3245,12 @@ INSERT INTO typed_tickets ( ) VALUES ('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), ('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); -INSERT INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +INSERT INTO workspace_resource_keys ( + workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), ('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); -INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +INSERT INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence) VALUES ('workspace-test', 'ticket', 3); "#, ) diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 1add0d14..70a81068 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -247,7 +247,7 @@ pub struct WorkerSummary { #[serde(flatten)] pub worker: RuntimeWorkerRef, #[serde(default, skip_serializing_if = "Option::is_none")] - pub human_key: Option, + pub resource_key: Option, pub host_id: String, /// Human-readable display name. This is not identity and may be duplicated. pub display_name: String, @@ -1680,7 +1680,7 @@ impl EmbeddedWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), - human_key: None, + resource_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -1720,7 +1720,7 @@ impl EmbeddedWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), - human_key: None, + resource_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -2806,7 +2806,7 @@ impl RemoteWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), - human_key: None, + resource_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -2850,7 +2850,7 @@ impl RemoteWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), - human_key: None, + resource_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -4222,7 +4222,7 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { let host_id = host_id.into(); WorkerSummary { worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"), - human_key: None, + resource_key: None, host_id, display_name: "Worker runtime actions are not implemented".to_string(), label: "Worker runtime actions are not implemented".to_string(), @@ -4616,7 +4616,7 @@ mod tests { host_id: host_id.to_string(), workers: vec![WorkerSummary { worker: RuntimeWorkerRef::new(runtime_id, worker_id), - human_key: None, + resource_key: None, host_id: host_id.to_string(), display_name: label.to_string(), label: label.to_string(), diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 1f9cb09b..26e15cda 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -31,7 +31,7 @@ pub struct InvalidProjectRecord { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketSummary { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, pub priority: String, @@ -67,7 +67,7 @@ pub struct TicketListResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, pub readiness: Option, @@ -124,7 +124,7 @@ pub struct TicketRelation { pub kind: String, pub target: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_human_key: Option, + pub target_resource_key: Option, pub note: Option, pub author: String, pub at: String, @@ -135,7 +135,7 @@ pub struct TicketRelation { pub struct DerivedTicketRelation { pub source_ticket: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_human_key: Option, + pub source_resource_key: Option, pub inverse_kind: String, pub forward_kind: String, pub note: Option, @@ -148,7 +148,7 @@ pub struct DerivedTicketRelation { pub struct TicketRelationBlocker { pub blocking_ticket: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub blocking_human_key: Option, + pub blocking_resource_key: Option, pub reason_kind: String, pub relation_kind: String, pub note: Option, @@ -182,7 +182,7 @@ impl From for TicketRelationView { ticket_id: relation.ticket_id, kind: relation.kind.as_str().to_string(), target: relation.target, - target_human_key: None, + target_resource_key: None, note: relation.note, author: relation.author, at: relation.at, @@ -193,7 +193,7 @@ impl From for TicketRelationView { .into_iter() .map(|relation| DerivedTicketRelation { source_ticket: relation.source_ticket, - source_human_key: None, + source_resource_key: None, inverse_kind: relation.inverse_kind, forward_kind: relation.forward_kind.as_str().to_string(), note: relation.note, @@ -206,7 +206,7 @@ impl From for TicketRelationView { .into_iter() .map(|blocker| TicketRelationBlocker { blocking_ticket: blocker.blocking_ticket, - blocking_human_key: None, + blocking_resource_key: None, reason_kind: blocker.reason_kind, relation_kind: blocker.relation_kind.as_str().to_string(), note: blocker.note, @@ -242,7 +242,7 @@ pub struct QueryPage { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveLinkSummary { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, } @@ -265,7 +265,7 @@ pub struct TicketAssignmentSummary { pub runtime_id: String, pub worker_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub worker_human_key: Option, + pub worker_resource_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -342,7 +342,7 @@ pub struct TicketQueryRequest { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryItem { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, pub readiness: Option, @@ -394,6 +394,7 @@ pub struct ObjectiveQueryRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveQueryItem { pub id: String, + pub resource_key: String, pub title: String, pub state: String, pub created_at: Option, @@ -429,7 +430,7 @@ pub struct ObjectiveEventDetail { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveLinkedTicketSummary { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, } @@ -437,7 +438,7 @@ pub struct ObjectiveLinkedTicketSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, pub created_at: Option, @@ -450,7 +451,7 @@ pub struct ObjectiveSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveDetail { pub id: String, - pub human_key: String, + pub resource_key: String, pub title: String, pub state: String, pub revision: String, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6e80ff11..e3046592 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4562,7 +4562,7 @@ async fn scoped_show_merge_request( .map(|ticket_id| { Ok(MergeRequestLinkedTicketResponse { ticket_id: ticket_id.clone(), - key: api.store.resource_human_key( + key: api.store.resource_key( &workspace_id, WorkspaceResourceKind::Ticket, ticket_id, @@ -10072,11 +10072,20 @@ async fn get_runtime_worker( .list_workdir_registry(&api.config.workspace_id, 500)?; let updated_at = record.updated_at.clone(); let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs); - worker.human_key = api.store.resource_human_key( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - &worker_ref.worker_id, - )?; + worker.resource_key = Some( + api.store + .resource_key( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + &worker_ref.worker_id, + )? + .ok_or_else(|| { + Error::Store(format!( + "Workspace Worker `{}` has no resource key", + worker_ref.worker_id + )) + })?, + ); Ok(Json(WorkerShowProjection { worker, updated_at })) } @@ -10094,12 +10103,22 @@ async fn restore_runtime_worker( let workdirs = api .store .list_workdir_registry(&api.config.workspace_id, 500)?; - result.worker = Some(merge_worker_registry_projection( - Some(worker), - &record, - links, - &workdirs, - )); + let mut summary = merge_worker_registry_projection(Some(worker), &record, links, &workdirs); + summary.resource_key = Some( + api.store + .resource_key( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + &record.worker.worker_id, + )? + .ok_or_else(|| { + Error::Store(format!( + "Workspace Worker `{}` has no resource key", + record.worker.worker_id + )) + })?, + ); + result.worker = Some(summary); } Ok(Json(WorkerRestoreResponse { workspace_id: api.workspace_id().to_string(), @@ -11183,11 +11202,20 @@ fn workers_response(api: WorkspaceApi) -> ApiResult WorkerSummary { WorkerSummary { worker: record.worker.clone(), - human_key: None, + resource_key: None, host_id: "backend-registry".to_string(), display_name: record.display_name.clone(), label: record.display_name.clone(), @@ -16207,10 +16235,11 @@ mod tests { .unwrap() .create(ticket::NewTicket::new("Browser Ticket API")) .unwrap(); - let ticket_human_key = ticket_ref.human_key.clone().unwrap(); + let ticket_resource_key = ticket_ref.resource_key.clone().unwrap(); let ticket_id = ticket_ref.id; assert_eq!( - resolve_workspace_ticket_reference(&api, TEST_WORKSPACE_ID, &ticket_human_key).unwrap(), + resolve_workspace_ticket_reference(&api, TEST_WORKSPACE_ID, &ticket_resource_key) + .unwrap(), ticket_id ); let path = || ScopedRecordPath { @@ -18317,7 +18346,19 @@ mod tests { let store = SqliteWorkspaceStore::in_memory().unwrap(); let mut config = test_server_config(dir.path()); - write_ticket( + let sqlite_store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); + sqlite_store + .upsert_workspace(&WorkspaceRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + owner_account_id: None, + display_name: "Test Workspace".to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }) + .await + .unwrap(); + let ticket_id = write_ticket( &config.database_path, TEST_WORKSPACE_ID, "API Ticket", @@ -18353,7 +18394,7 @@ mod tests { &[ObjectiveTicketLinkRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), objective_id: "00000000001J3".to_string(), - ticket_id: "00000000001J2".to_string(), + ticket_id: ticket_id.clone(), kind: "linked".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), }], @@ -18550,7 +18591,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"), Some(json!({ "query": "Objective body", - "linked_ticket_id": "00000000001J2", + "linked_ticket_id": ticket_id, "limit": 1 })), StatusCode::OK, @@ -18566,7 +18607,7 @@ mod tests { StatusCode::OK, ) .await; - assert_eq!(shown_objective["linked_tickets"][0], "00000000001J2"); + assert_eq!(shown_objective["linked_tickets"][0], ticket_id); assert!(shown_objective["event_page"]["returned"].is_number()); let memory_document = @@ -19459,16 +19500,23 @@ mod tests { }; let response: protocol::subscription::SubscriptionFrame = serde_json::from_str(text.as_str()).unwrap(); - assert!(matches!( - response.payload, + let workers = match response.payload { protocol::subscription::SubscriptionFramePayload::Response( protocol::subscription::SubscriptionResponse::Subscribed { selector: protocol::subscription::EventSubscriptionSelector::WorkspaceWorkers, - snapshot: protocol::subscription::SubscriptionSnapshot::Workers { .. }, + snapshot: protocol::subscription::SubscriptionSnapshot::Workers { workers }, .. - } - ) - )); + }, + ) => workers, + other => panic!("expected Workspace Worker snapshot, got {other:?}"), + }; + assert_eq!( + workers + .iter() + .find(|worker| worker.worker_id.as_str() == worker_id) + .and_then(|worker| worker.resource_key.as_deref()), + Some("W-1") + ); let subscribe_protocol = protocol::subscription::SubscriptionFrame::new( protocol::subscription::SubscriptionFramePayload::Request( @@ -19805,12 +19853,12 @@ INSERT INTO typed_tickets ( ) VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); -INSERT INTO workspace_resource_human_keys ( - workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +INSERT INTO workspace_resource_keys ( + workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); -INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +INSERT INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence) VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); "#, ) @@ -20027,13 +20075,13 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); workspace_id: &str, title: &str, state: ticket::TicketWorkflowState, - ) { + ) -> String { use ticket::TicketBackend as _; let backend = ticket::SqliteTicketBackend::open(database_path, workspace_id).unwrap(); let mut input = ticket::NewTicket::new(title); input.workflow_state = Some(state); - backend.create(input).unwrap(); + backend.create(input).unwrap().id } fn write_objective(root: &Path, id: &str, title: &str, state: &str) { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 0dab51cc..339a0782 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -225,6 +225,11 @@ const MIGRATIONS: &[Migration] = &[ 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, + }, ]; struct Migration { @@ -595,7 +600,7 @@ impl WorkspaceResourceKind { #[async_trait] pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; - fn resource_human_key( + fn resource_key( &self, workspace_id: &str, kind: WorkspaceResourceKind, @@ -1059,6 +1064,14 @@ impl SqliteWorkspaceStore { } 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, i64::MAX)?; ticket::migrate_sqlite_ticket_schema(&candidate)?; merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?; @@ -1171,7 +1184,7 @@ impl SqliteWorkspaceStore { let worker_id = WorkerId::now_v7(); let now = chrono::Utc::now().to_rfc3339(); - allocate_resource_human_key( + allocate_resource_key( &tx, workspace_id, WorkspaceResourceKind::Worker, @@ -1304,7 +1317,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(current_schema_version) } - fn resource_human_key( + fn resource_key( &self, workspace_id: &str, kind: WorkspaceResourceKind, @@ -1312,7 +1325,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { conn.query_row( - "SELECT human_key FROM workspace_resource_human_keys + "SELECT resource_key FROM workspace_resource_keys WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3", params![workspace_id, kind.as_str(), resource_id], |row| row.get(0), @@ -1331,8 +1344,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { if let Some(resource_id) = conn .query_row( - "SELECT resource_id FROM workspace_resource_human_keys - WHERE workspace_id = ?1 AND resource_kind = ?2 AND human_key = ?3", + "SELECT resource_id FROM workspace_resource_keys + WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_key = ?3", params![workspace_id, kind.as_str(), reference], |row| row.get(0), ) @@ -1536,7 +1549,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } for resource_kind in ["ticket", "objective", "worker"] { tx.execute( - r#"INSERT OR IGNORE INTO workspace_resource_human_key_counters ( + r#"INSERT OR IGNORE INTO workspace_resource_key_counters ( workspace_id, resource_kind, next_sequence ) VALUES (?1, ?2, 1)"#, params![record.workspace.workspace_id, resource_kind], @@ -1979,7 +1992,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> { self.with_conn(|conn| { let tx = conn.unchecked_transaction()?; - allocate_resource_human_key( + allocate_resource_key( &tx, &record.workspace_id, WorkspaceResourceKind::Objective, @@ -2757,7 +2770,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()> { self.with_conn(|conn| { - let removal_blocks_upsert: bool = conn.query_row( + let tx = conn.unchecked_transaction()?; + let removal_blocks_upsert: bool = tx.query_row( "SELECT EXISTS( SELECT 1 FROM worker_removal_operations WHERE workspace_id = ?1 AND runtime_id = ?2 @@ -2774,7 +2788,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if removal_blocks_upsert { return Ok(()); } - conn.execute( + tx.execute( r#"INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, profile, retention_state, transcript_ref, session_ref, summary_ref, @@ -2816,6 +2830,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.updated_at, ], )?; + allocate_resource_key( + &tx, + &record.workspace_id, + WorkspaceResourceKind::Worker, + &record.worker.worker_id, + &record.created_at, + )?; + tx.commit()?; Ok(()) }) } @@ -5527,8 +5549,11 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Result ' || 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) \ - {tombstone_filter} LIMIT 100" - ), - "ticket_worker_assignments.worker_id", - &mut diagnostics, - )?; + collect_assignment_worker_reference_diagnostics(conn, &mut diagnostics)?; } if table_exists(conn, "ticket_assignment_operations")? && table_exists(conn, "typed_tickets")? { let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? { @@ -5604,6 +5608,114 @@ 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 \ + AND tombstone.runtime_id = assignment.runtime_id \ + AND tombstone.worker_id = assignment.worker_id)" + } else { + "" + }; + let sql = format!( + "SELECT assignment.workspace_id, assignment.assignment_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) \ + {tombstone_filter}" + ); + 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)?, + row.get::<_, String>(3)?, + )) + })?; + 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}" + )); + worker_diagnostic_count += 1; + if worker_diagnostic_count == 100 { + break; + } + } + 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, @@ -6132,7 +6244,7 @@ fn promote_workspace_worker_uuid_identity( Ok(mappings) } -fn allocate_resource_human_key( +fn allocate_resource_key( conn: &Connection, workspace_id: &str, kind: WorkspaceResourceKind, @@ -6141,7 +6253,7 @@ fn allocate_resource_human_key( ) -> Result { if let Some(existing) = conn .query_row( - "SELECT human_key FROM workspace_resource_human_keys + "SELECT resource_key FROM workspace_resource_keys WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3", params![workspace_id, kind.as_str(), resource_id], |row| row.get(0), @@ -6151,36 +6263,36 @@ fn allocate_resource_human_key( return Ok(existing); } conn.execute( - "INSERT OR IGNORE INTO workspace_resource_human_key_counters + "INSERT OR IGNORE INTO workspace_resource_key_counters (workspace_id, resource_kind, next_sequence) VALUES (?1, ?2, 1)", params![workspace_id, kind.as_str()], )?; let sequence: i64 = conn.query_row( - "SELECT next_sequence FROM workspace_resource_human_key_counters + "SELECT next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 AND resource_kind = ?2", params![workspace_id, kind.as_str()], |row| row.get(0), )?; conn.execute( - "UPDATE workspace_resource_human_key_counters SET next_sequence = ?3 + "UPDATE workspace_resource_key_counters SET next_sequence = ?3 WHERE workspace_id = ?1 AND resource_kind = ?2", params![workspace_id, kind.as_str(), sequence + 1], )?; - let human_key = format!("{}-{sequence}", kind.prefix()); + let resource_key = format!("{}-{sequence}", kind.prefix()); conn.execute( - "INSERT INTO workspace_resource_human_keys - (workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at) + "INSERT INTO workspace_resource_keys + (workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ workspace_id, kind.as_str(), resource_id, sequence, - human_key, + resource_key, allocated_at ], )?; - Ok(human_key) + Ok(resource_key) } fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> { @@ -6261,6 +6373,47 @@ fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> { 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")?; @@ -6530,6 +6683,21 @@ CREATE TABLE ticket_worker_assignments_v39 ( 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, @@ -6830,17 +6998,66 @@ 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 { - ticket::migrate_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "migration 39 Ticket schema preparation failed: {error}" - )) - })?; + 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(), @@ -6856,7 +7073,11 @@ pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; let result = (|| -> Result<()> { let tx = conn.unchecked_transaction()?; - (migration.apply)(&tx)?; + 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(), @@ -7461,7 +7682,7 @@ mod tests { 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, 40); + assert_eq!(plan.target_schema_version, 41); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -7475,14 +7696,14 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 40); + assert_eq!(current_schema_version(conn)?, 41); Ok(()) }) .unwrap(); } #[test] - fn v38_backfills_workspace_scoped_objective_and_worker_human_keys() { + 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(); @@ -7518,11 +7739,11 @@ mod tests { ).unwrap(); } - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); apply_migrations(&conn).unwrap(); let mut statement = conn .prepare( - "SELECT resource_kind, resource_id, human_key FROM workspace_resource_human_keys + "SELECT resource_kind, resource_id, resource_key FROM workspace_resource_keys ORDER BY resource_kind, sequence", ) .unwrap(); @@ -7554,7 +7775,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -7683,7 +7904,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -7801,7 +8022,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -7834,7 +8055,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -7901,7 +8122,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -8079,7 +8300,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 40); + assert_eq!(store.schema_version().await.unwrap(), 41); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -8096,7 +8317,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 40); + assert_eq!(reopened.schema_version().await.unwrap(), 41); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -8104,7 +8325,7 @@ INSERT INTO workdir_registry ( } #[tokio::test] - async fn objective_creation_allocates_and_resolves_workspace_human_key() { + async fn objective_creation_allocates_and_resolves_workspace_resource_key() { let dir = tempfile::tempdir().unwrap(); let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); store @@ -8131,7 +8352,7 @@ INSERT INTO workdir_registry ( .unwrap(); assert_eq!( store - .resource_human_key( + .resource_key( "workspace-a", WorkspaceResourceKind::Objective, "objective-internal" @@ -8185,7 +8406,7 @@ INSERT INTO workdir_registry ( ); assert_eq!( store - .resource_human_key( + .resource_key( "workspace-a", WorkspaceResourceKind::Worker, &reserved.to_string() @@ -8205,7 +8426,7 @@ INSERT INTO workdir_registry ( .unwrap(); assert_eq!( store - .resource_human_key( + .resource_key( "workspace-a", WorkspaceResourceKind::Worker, &second.to_string() @@ -8595,13 +8816,13 @@ INSERT INTO worker_registry ( configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (41, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (42, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 41 is newer"), "{error}"); + assert!(error.contains("schema version 42 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -8713,7 +8934,7 @@ INSERT INTO worker_registry ( let conn = Connection::open(&path).unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); merge_request::migrate(&conn).unwrap(); conn.execute_batch( r#" @@ -8759,7 +8980,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema(&conn).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) \ @@ -8822,7 +9043,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- apply_migrations(&mut conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 40); + assert_eq!(current_schema_version(&conn).unwrap(), 41); let workspace_id: Option = conn .query_row( "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", @@ -8839,7 +9060,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations_through(&conn, 38).unwrap(); - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + ticket::migrate_sqlite_ticket_schema_through(&conn, 5).unwrap(); merge_request::migrate(&conn).unwrap(); conn.execute_batch( @@ -8937,7 +9158,6 @@ INSERT INTO ticket_worker_assignment_events ( "{error}" ); assert!(error.contains("assignment-cross-worker"), "{error}"); - assert!(error.contains("assignment-runtime-mismatch"), "{error}"); assert!(error.contains("assignment-missing-parents"), "{error}"); assert!( error.contains("ticket_worker_assignment_events.assignment_id"), @@ -9117,6 +9337,123 @@ INSERT INTO ticket_worker_assignment_events ( 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(); + + 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(); @@ -9323,7 +9660,7 @@ INSERT INTO ticket_worker_assignment_events ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 40); + assert_eq!(store.schema_version().await.unwrap(), 41); store .with_conn(|conn| { @@ -9512,7 +9849,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 40); + assert_eq!(store.schema_version().await.unwrap(), 41); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -9578,7 +9915,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 40); + assert_eq!(store.schema_version().await.unwrap(), 41); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -9698,6 +10035,17 @@ CREATE TABLE ticket_assignment_operations ( runtime_sync_worker.retention_state = "normal".to_string(); runtime_sync_worker.updated_at = "5".to_string(); store.upsert_worker_registry(&runtime_sync_worker).unwrap(); + assert_eq!( + store + .resource_key( + "local-dev", + WorkspaceResourceKind::Worker, + &worker.worker.worker_id, + ) + .unwrap() + .as_deref(), + Some("W-1") + ); let mut expected_worker = worker.clone(); expected_worker.updated_at = "5".to_string(); @@ -9969,7 +10317,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 40); + assert_eq!(store.schema_version().await.unwrap(), 41); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 0e5619ca..7a7598d0 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -12,6 +12,7 @@ use worker_runtime::identity::RuntimeWorkerRef; use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker}; use crate::server::{WorkspaceApi, connect_workspace_worker_protocol}; +use crate::store::WorkspaceResourceKind; const OUTBOUND_CAPACITY: usize = 256; @@ -65,6 +66,7 @@ pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebS match selector { EventSubscriptionSelector::WorkspaceWorkers => { let task = tokio::spawn(run_workspace_workers( + api.clone(), broker.clone(), request_id, subscription_id.clone(), @@ -273,6 +275,7 @@ async fn run_worker_protocol( } async fn run_workspace_workers( + api: WorkspaceApi, broker: RuntimeSubscriptionBroker, request_id: protocol::subscription::SubscriptionRequestId, subscription_id: SubscriptionId, @@ -307,7 +310,7 @@ async fn run_workspace_workers( }; match event { BrokerSubscriptionEvent::Snapshot { snapshot, .. } => { - install_snapshot(&mut workers, &runtime_id, snapshot); + install_snapshot(&api, &mut workers, &runtime_id, snapshot); pending.remove(&runtime_id); } BrokerSubscriptionEvent::Disconnected { .. } @@ -371,7 +374,7 @@ async fn run_workspace_workers( return; } } - install_snapshot(&mut workers, &runtime_id, snapshot); + install_snapshot(&api, &mut workers, &runtime_id, snapshot); if let Some(current) = workers.get_mut(&runtime_id) { for worker in current.values_mut() { let worker_ref = @@ -397,6 +400,14 @@ async fn run_workspace_workers( BrokerSubscriptionEvent::Event { payload, .. } => match payload { SubscriptionEventPayload::WorkerUpserted { mut worker } => { worker.runtime_id = Some(runtime_id.clone()); + let Ok(Some(resource_key)) = api.store.resource_key( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + worker.worker_id.as_str(), + ) else { + continue; + }; + worker.resource_key = Some(resource_key); let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str()); let revision = next_revision(&mut revisions, &worker_ref); worker.subject_revision = revision; @@ -474,6 +485,7 @@ async fn run_workspace_workers( } fn install_snapshot( + api: &WorkspaceApi, workers: &mut HashMap>, runtime_id: &str, snapshot: SubscriptionSnapshot, @@ -487,6 +499,14 @@ fn install_snapshot( let mut projected = BTreeMap::new(); for mut worker in snapshot_workers { worker.runtime_id = Some(runtime_id.to_string()); + let Ok(Some(resource_key)) = api.store.resource_key( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + worker.worker_id.as_str(), + ) else { + continue; + }; + worker.resource_key = Some(resource_key); projected.insert(worker.worker_id.to_string(), worker); } workers.insert(runtime_id.to_string(), projected); diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index aac1c97e..9d892f44 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -21,6 +21,7 @@ Start exactly one instance of the new Server binary against the database. Startu - rebuilds Ticket, Objective, assignment, Artifact, and human-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. diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 1a1fbaf4..5faf77fa 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -135,6 +135,11 @@ export type SubscriptionWorker = { worker_id: SubscriptionWorkerId, * Runtime producers leave this unset because the connection identifies the Runtime. */ runtime_id?: string | null, +/** + * Workspace-scoped canonical resource key. Runtime producers leave this unset; + * Workspace-facing projections must populate it before publishing the Worker. + */ +resource_key?: string | null, /** * Producer-owned monotonic revision for this Worker subject. */ diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b3424f6f..2d85680e 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -5,7 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string }; export type TicketSummary = { id: string; - human_key: string; + resource_key: string; title: string; state: string; priority: string; @@ -54,7 +54,7 @@ export type TicketEventDetail = { export type ObjectiveLinkSummary = { id: string; - human_key: string; + resource_key: string; title: string; state: string; }; @@ -72,7 +72,7 @@ export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; - worker_human_key?: string | null; + worker_resource_key?: string | null; }; export type TicketMergeRequestSummary = { @@ -133,7 +133,7 @@ export type TicketQueryRequest = { export type TicketQueryItem = { id: string; - human_key: string; + resource_key: string; title: string; state: string; readiness: string | null; @@ -169,7 +169,7 @@ export type TicketRelation = { ticket_id: string; kind: string; target: string; - target_human_key?: string | null; + target_resource_key?: string | null; note: string | null; author: string; at: string; @@ -177,7 +177,7 @@ export type TicketRelation = { export type DerivedTicketRelation = { source_ticket: string; - source_human_key?: string | null; + source_resource_key?: string | null; inverse_kind: string; forward_kind: string; note: string | null; @@ -187,7 +187,7 @@ export type DerivedTicketRelation = { export type TicketRelationBlocker = { blocking_ticket: string; - blocking_human_key?: string | null; + blocking_resource_key?: string | null; reason_kind: string; relation_kind: string; note: string | null; @@ -209,7 +209,7 @@ export type TicketRelationView = { export type TicketDetail = { id: string; - human_key: string; + resource_key: string; title: string; state: string; readiness: string | null; diff --git a/web/workspace/src/lib/workspace/resource-links.ts b/web/workspace/src/lib/workspace/resource-links.ts index 0bbc3996..67268544 100644 --- a/web/workspace/src/lib/workspace/resource-links.ts +++ b/web/workspace/src/lib/workspace/resource-links.ts @@ -1,7 +1,7 @@ -const HUMAN_KEY_PATTERN = /^(T|O|W)-(\d+)/; +const RESOURCE_KEY_PATTERN = /^(T|O|W)-(\d+)/; -export function resourceHumanKey(reference: string): string { - const match = HUMAN_KEY_PATTERN.exec(reference); +export function resourceKey(reference: string): string { + const match = RESOURCE_KEY_PATTERN.exec(reference); return match ? `${match[1]}-${match[2]}` : reference; } @@ -18,32 +18,30 @@ export function slugifyResourceTitle(title: string): string { } export function canonicalResourceReference( - humanKey: string, + resourceKey: string, title: string, ): string { - return `${humanKey}-${slugifyResourceTitle(title)}`; + return `${resourceKey}-${slugifyResourceTitle(title)}`; } export function ticketHref( workspaceId: string, - ticket: { human_key: string; title: string }, + ticket: { resource_key: string; title: string }, ): string { - return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.human_key, ticket.title))}`; + return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.resource_key, ticket.title))}`; } export function objectiveHref( workspaceId: string, - objective: { human_key: string; title: string }, + objective: { resource_key: string; title: string }, ): string { - return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.human_key, objective.title))}`; + return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.resource_key, objective.title))}`; } export function workerHref( workspaceId: string, - worker: { human_key?: string; display_name: string; worker_id: string }, + worker: { resource_key: string; display_name: string }, ): string { - const reference = worker.human_key - ? canonicalResourceReference(worker.human_key, worker.display_name) - : worker.worker_id; + const reference = canonicalResourceReference(worker.resource_key, worker.display_name); return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`; } diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 267e8961..43afa4f2 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -76,7 +76,7 @@ export type WorkerCapabilities = { export type Worker = { runtime_id: string; worker_id: string; - human_key?: string; + resource_key: string; host_id: string; display_name: string; label: string; @@ -404,7 +404,7 @@ export type { export type ObjectiveSummary = { id: string; - human_key: string; + resource_key: string; title: string; state: string; updated_at?: string | null; @@ -415,14 +415,14 @@ export type ObjectiveSummary = { export type ObjectiveLinkedTicketSummary = { id: string; - human_key: string; + resource_key: string; title: string; state: string; }; export type ObjectiveDetail = { id: string; - human_key: string; + resource_key: string; title: string; state: string; created_at?: string | null; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts index d7927834..5baf4cc8 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts @@ -74,10 +74,12 @@ export function workspaceWorkersStore(workspaceId: string): Readable): Worker { return { runtime_id: "arc", worker_id: "1", + resource_key: "W-1", host_id: "host", display_name: "Worker 1", label: "Worker 1", diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 9a01568e..38e9b99f 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; export type TicketCardSummary = Pick< TicketSummary, - "id" | "human_key" | "title" | "state" | "priority" | "updated_at" + "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" >; const STATE_SORT_ORDER = new Map([ diff --git a/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte index eb923185..4b7a14f3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte @@ -30,7 +30,7 @@
Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'} {objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'} - {objective.human_key} + {objective.resource_key}
{/each} diff --git a/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.svelte index 2e88b2f9..dff8b622 100644 --- a/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.svelte @@ -25,7 +25,7 @@
Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'} - {objective.human_key} + {objective.resource_key}
{/each} @@ -64,7 +64,7 @@
{#if data.objective.linked_ticket_summaries.length} {#each data.objective.linked_ticket_summaries as ticket, index} - {#if index}, {/if}{ticket.human_key} + {#if index}, {/if}{ticket.resource_key} {/each} {:else} none diff --git a/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.ts index bd7daea0..bf4b1592 100644 --- a/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/objectives/[objectiveId]/+page.ts @@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { canonicalResourceReference, - resourceHumanKey, + resourceKey, } from "$lib/workspace/resource-links"; import type { ObjectiveDetail, @@ -12,7 +12,7 @@ import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); - const objectiveId = resourceHumanKey(params.objectiveId); + const objectiveId = resourceKey(params.objectiveId); const [objectives, objective] = await Promise.all([ loadJson(fetch, apiPath("/objectives")), loadJson( @@ -23,7 +23,7 @@ export const load: PageLoad = async ({ fetch, params }) => { if (objective.data) { const canonical = canonicalResourceReference( - objective.data.human_key, + objective.data.resource_key, objective.data.title, ); if (params.objectiveId !== canonical) { diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 0fdb7b56..f20a36d1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -179,7 +179,7 @@ class="ticket-card" href={ticketHref(data.workspaceId, ticket)} > - {ticket.human_key} + {ticket.resource_key} {ticket.title}
{ticket.state} · {ticket.priority} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index 22a457b3..3e4872d3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -255,27 +255,42 @@ {#if ticket.relations.blockers.length > 0}
{#each ticket.relations.blockers as blocker} - - Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket} - {relationLabel(blocker.relation_kind)} · {blocker.blocking_state} - + {#if blocker.blocking_resource_key} + + Blocked by {blocker.blocking_resource_key} + {relationLabel(blocker.relation_kind)} · {blocker.blocking_state} + + {:else} +
+ Blocked by resource key unavailable + {relationLabel(blocker.relation_kind)} · {blocker.blocking_state} +
+ {/if} {/each}
{/if}
{#each ticket.relations.outgoing as relation} - - {relationLabel(relation.kind)} - {relation.target_human_key ?? relation.target} - {#if relation.note}{relation.note}{/if} - + {#if relation.target_resource_key} + + {relationLabel(relation.kind)} + {relation.target_resource_key} + {#if relation.note}{relation.note}{/if} + + {:else} + resource key unavailable + {/if} {/each} {#each ticket.relations.incoming as relation} - - {relationLabel(relation.inverse_kind)} - {relation.source_human_key ?? relation.source_ticket} - {#if relation.note}{relation.note}{/if} - + {#if relation.source_resource_key} + + {relationLabel(relation.inverse_kind)} + {relation.source_resource_key} + {#if relation.note}{relation.note}{/if} + + {:else} + resource key unavailable + {/if} {/each} {#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}

No Ticket relations.

diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts index c8e9ebba..3f5bb339 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts @@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { canonicalResourceReference, - resourceHumanKey, + resourceKey, } from "$lib/workspace/resource-links"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { @@ -12,7 +12,7 @@ import type { import type { PageLoad } from "./$types"; export const load = (async ({ fetch, params }) => { - const reference = resourceHumanKey(params.ticketId); + const reference = resourceKey(params.ticketId); const ticketPath = workspaceApiPath( params.workspaceId, `/tickets/${encodeURIComponent(reference)}`, @@ -30,7 +30,7 @@ export const load = (async ({ fetch, params }) => { ]); if (ticket.data) { const canonical = canonicalResourceReference( - ticket.data.human_key, + ticket.data.resource_key, ticket.data.title, ); if (params.ticketId !== canonical) { diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte index 33f49ab5..f3b79cb0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -194,12 +194,12 @@ {@const workerDisplayName = worker.display_name || worker.label} - {#if canOpenWorkerConsole(worker)} - {workerDisplayName} + {#if canOpenWorkerConsole(worker) && worker.resource_key} + {workerDisplayName} {:else} {workerDisplayName} {/if} - worker {worker.human_key ?? worker.worker_id} + worker {worker.resource_key} {worker.runtime_id} {workerProfile(worker)} diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.svelte index bb2bfd71..ef3d04bb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.svelte @@ -4,7 +4,7 @@ let { data }: { data: PageData } = $props(); -{data.worker?.human_key ?? 'Worker'} · Yoi +{data.worker?.resource_key ?? 'Worker'} · Yoi
{#if data.workerError} @@ -12,7 +12,7 @@ {:else if data.worker}
-

{data.worker.human_key}

+

{data.worker.resource_key}

{data.worker.display_name}

{ - const reference = resourceHumanKey(params.workerRef); + const reference = resourceKey(params.workerRef); const result = await loadJson( fetch, workspaceApiPath( @@ -16,9 +16,9 @@ export const load = (async ({ fetch, params }) => { `/workers/${encodeURIComponent(reference)}`, ), ); - if (result.data?.human_key) { + if (result.data?.resource_key) { const canonical = canonicalResourceReference( - result.data.human_key, + result.data.resource_key, result.data.display_name, ); if (params.workerRef !== canonical) { diff --git a/web/workspace/tests/resource-links.test.ts b/web/workspace/tests/resource-links.test.ts index d3d53330..f64bdda1 100644 --- a/web/workspace/tests/resource-links.test.ts +++ b/web/workspace/tests/resource-links.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { canonicalResourceReference, - resourceHumanKey, + resourceKey, slugifyResourceTitle, } from "../src/lib/workspace/resource-links.ts"; @@ -11,16 +11,16 @@ function assertEquals(actual: unknown, expected: unknown): void { } } -Deno.test("resource links normalize titles and preserve the human key", () => { +Deno.test("resource links normalize titles and preserve the resource key", () => { assertEquals(slugifyResourceTitle(" Fix stale URL / 日本語 "), "fix-stale-url-日本語"); assertEquals( canonicalResourceReference("T-1842", "Fix stale URL / 日本語"), "T-1842-fix-stale-url-日本語", ); - assertEquals(resourceHumanKey("T-1842-fix-stale-url-日本語"), "T-1842"); + assertEquals(resourceKey("T-1842-fix-stale-url-日本語"), "T-1842"); }); Deno.test("resource links use a deterministic fallback for punctuation-only titles", () => { assertEquals(canonicalResourceReference("O-7", "---"), "O-7-resource"); - assertEquals(resourceHumanKey("01a017internal"), "01a017internal"); + assertEquals(resourceKey("01a017internal"), "01a017internal"); });