chore: merge develop into work/companion

This commit is contained in:
2026-08-21 11:38:47 +09:00
34 changed files with 1124 additions and 319 deletions
+2 -2
View File
@@ -137,8 +137,7 @@ pub struct BackendWorkerCapabilitySummary {
pub struct BackendWorkerSummary {
pub runtime_id: String,
pub worker_id: String,
#[serde(default)]
pub human_key: Option<String>,
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",
+8
View File
@@ -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<String>,
/// 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<String>,
/// 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,
+49 -38
View File
@@ -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<TicketSu
let workflow_state = row.get::<_, String>(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<String>,
pub resource_key: Option<String>,
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<String>,
pub resource_key: Option<String>,
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<String>,
pub resource_key: Option<String>,
pub slug: String,
pub title: String,
pub status: ExtensibleTicketStatus,
@@ -2677,7 +2679,7 @@ impl SqliteTicketBackend {
.collect::<std::result::Result<Vec<_>, _>>()
.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<Option<String>> {
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<String> {
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<bool> {
@@ -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<String>) = 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()
})
})
+359 -16
View File
@@ -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)?;
}
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::<BTreeMap<_, _>>();
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<bool> {
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<String>,
) {
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<String>,
) {
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::<std::result::Result<Vec<_>, _>>() {
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::<std::result::Result<Vec<_>, _>>() {
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,8 +1292,8 @@ 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'
@@ -1044,7 +1307,7 @@ mod tests {
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
keys,
legacy_keys,
vec![
("earlier".into(), "T-1".into()),
("later".into(), "T-2".into())
@@ -1059,6 +1322,50 @@ mod tests {
)
.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::<std::result::Result<Vec<_>, _>>()
.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<String>,Option<String>,Option<String>)=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);
}
}
+6 -13
View File
@@ -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::<String>();
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:—"));
+2 -2
View File
@@ -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(),
+9 -3
View File
@@ -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),
+3 -3
View File
@@ -253,7 +253,7 @@ impl NextUserAction {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketPanelEntry {
pub(crate) id: String,
pub(crate) human_key: Option<String>,
pub(crate) resource_key: Option<String>,
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,
+1
View File
@@ -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)
+1 -1
View File
@@ -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,
})
+43 -39
View File
@@ -228,10 +228,10 @@ impl SqliteWorkspaceAuthority {
self
}
fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
fn resource_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
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<ObjectiveRecord> {
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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<TicketSummary> {
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<TicketSummary>
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketSummary> {
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);
"#,
)
+7 -7
View File
@@ -247,7 +247,7 @@ pub struct WorkerSummary {
#[serde(flatten)]
pub worker: RuntimeWorkerRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub resource_key: Option<String>,
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<String>) -> 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(),
+15 -14
View File
@@ -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<String>,
@@ -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<String>,
pub target_resource_key: Option<String>,
pub note: Option<String>,
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<String>,
pub source_resource_key: Option<String>,
pub inverse_kind: String,
pub forward_kind: String,
pub note: Option<String>,
@@ -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<String>,
pub blocking_resource_key: Option<String>,
pub reason_kind: String,
pub relation_kind: String,
pub note: Option<String>,
@@ -182,7 +182,7 @@ impl From<ticket::TicketRelationView> 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<ticket::TicketRelationView> 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<ticket::TicketRelationView> 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<String>,
pub worker_resource_key: Option<String>,
}
#[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<String>,
@@ -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<String>,
@@ -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<String>,
@@ -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,
+77 -29
View File
@@ -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(
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<RuntimeListResponse<WorkerSu
links,
&workdir_records,
);
summary.human_key = api.store.resource_human_key(
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
))
})?,
);
items.push(summary);
}
Ok(RuntimeListResponse {
@@ -12056,7 +12084,7 @@ fn record_worker_summary(
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> 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) {
+420 -72
View File
@@ -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<i64>;
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<Option<String>> {
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<Vec<Str
}
// Assignment and operation rows are historical soft references. Schema v39 records an
// explicit tombstone before a live Ticket or Worker parent is deleted/moved; older schemas
// have no tombstone authority, so every missing live parent remains migration-blocking drift.
// explicit tombstone before a live Ticket or Worker parent is deleted/moved. A pre-v39
// assignment with a valid Worker UUID and no contradictory Worker authority in another
// Workspace is repairable legacy evidence; the migration materializes its tombstone.
// Missing Ticket parents remain migration-blocking because no equivalent legacy repair is
// currently defined.
if table_exists(conn, "ticket_worker_assignments")? && table_exists(conn, "typed_tickets")? {
let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? {
"AND NOT EXISTS (SELECT 1 FROM ticket_assignment_ticket_tombstones AS tombstone \
@@ -5556,28 +5581,7 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
&& column_exists(conn, "ticket_worker_assignments", "worker_id")?
&& column_exists(conn, "worker_registry", "worker_id")?
{
let tombstone_filter = if table_exists(conn, "ticket_assignment_worker_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 {
""
};
collect_reference_diagnostics(
conn,
&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} 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<Vec<Str
Ok(diagnostics)
}
fn collect_assignment_worker_reference_diagnostics(
conn: &Connection,
diagnostics: &mut Vec<String>,
) -> 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<std::collections::BTreeSet<(String, String, String)>> {
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<String> {
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| {
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()?;
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<String> = 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<String> = 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(),
@@ -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<String, BTreeMap<String, SubscriptionWorker>>,
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);
@@ -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.
@@ -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.
*/
@@ -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;
@@ -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)}`;
}
@@ -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;
@@ -74,10 +74,12 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
if (!worker.resource_key) throw new Error('Workspace Worker projection is missing resource_key');
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
return {
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
resource_key: worker.resource_key,
host_id: worker.runtime_id,
display_name: displayName,
label: displayName,
@@ -19,6 +19,7 @@ function worker(overrides: Partial<Worker>): Worker {
return {
runtime_id: "arc",
worker_id: "1",
resource_key: "W-1",
host_id: "host",
display_name: "Worker 1",
label: "Worker 1",
@@ -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<string, number>([
@@ -30,7 +30,7 @@
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<code>{objective.human_key}</code>
<code>{objective.resource_key}</code>
</div>
</a>
{/each}
@@ -25,7 +25,7 @@
</div>
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<code>{objective.human_key}</code>
<code>{objective.resource_key}</code>
</div>
</a>
{/each}
@@ -64,7 +64,7 @@
<dd>
{#if data.objective.linked_ticket_summaries.length}
{#each data.objective.linked_ticket_summaries as ticket, index}
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.human_key}</a>
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.resource_key}</a>
{/each}
{:else}
none
@@ -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<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>(
@@ -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) {
@@ -179,7 +179,7 @@
class="ticket-card"
href={ticketHref(data.workspaceId, ticket)}
>
<span class="ticket-card-id">{ticket.human_key}</span>
<span class="ticket-card-id">{ticket.resource_key}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span>
@@ -255,27 +255,42 @@
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_human_key ?? blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket}</strong>
{#if blocker.blocking_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
<strong>Blocked by {blocker.blocking_resource_key}</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{:else}
<div>
<strong>Blocked by resource key unavailable</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</div>
{/if}
{/each}
</div>
{/if}
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_human_key ?? relation.target)}`}>
{#if relation.target_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_resource_key)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target_human_key ?? relation.target}</strong>
<strong>{relation.target_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#each ticket.relations.incoming as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_human_key ?? relation.source_ticket)}`}>
{#if relation.source_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_resource_key)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_human_key ?? relation.source_ticket}</strong>
<strong>{relation.source_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
<p class="workspace-empty-copy">No Ticket relations.</p>
@@ -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) {
@@ -194,12 +194,12 @@
{@const workerDisplayName = worker.display_name || worker.label}
<tr>
<td>
{#if canOpenWorkerConsole(worker)}
<a class="worker-title-link" href={workerHref(data.workspaceId, worker)}><strong>{workerDisplayName}</strong></a>
{#if canOpenWorkerConsole(worker) && worker.resource_key}
<a class="worker-title-link" href={workerHref(data.workspaceId, { ...worker, resource_key: worker.resource_key })}><strong>{workerDisplayName}</strong></a>
{:else}
<strong>{workerDisplayName}</strong>
{/if}
<small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
<small>worker <code>{worker.resource_key}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
@@ -4,7 +4,7 @@
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{data.worker?.human_key ?? 'Worker'} · Yoi</title></svelte:head>
<svelte:head><title>{data.worker?.resource_key ?? 'Worker'} · Yoi</title></svelte:head>
<section class="workspace-page-shell">
{#if data.workerError}
@@ -12,7 +12,7 @@
{:else if data.worker}
<header class="workspace-page-header">
<div>
<p class="eyebrow">{data.worker.human_key}</p>
<p class="eyebrow">{data.worker.resource_key}</p>
<h1>{data.worker.display_name}</h1>
</div>
<a
@@ -2,13 +2,13 @@ import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
resourceKey,
} from "$lib/workspace/resource-links";
import type { Worker } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const reference = resourceHumanKey(params.workerRef);
const reference = resourceKey(params.workerRef);
const result = await loadJson<Worker>(
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) {
+4 -4
View File
@@ -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");
});