From 1ca36d6b66a90c87fa22dda0809f51aba0c0dba2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 02:17:00 +0900 Subject: [PATCH] feat: add workspace resource human keys --- crates/client/src/backend_runtime.rs | 2 + crates/ticket/src/lib.rs | 142 +++++++- crates/ticket/src/sqlite_schema.rs | 114 +++++- crates/tui/src/backend_worker_picker.rs | 4 + crates/tui/src/dashboard/render.rs | 7 +- crates/tui/src/dashboard/tests.rs | 8 +- crates/tui/src/workspace_panel.rs | 3 + crates/workspace-server/src/authority.rs | 129 +++++-- crates/workspace-server/src/hosts.rs | 8 + crates/workspace-server/src/records.rs | 18 + crates/workspace-server/src/server.rs | 100 +++++- crates/workspace-server/src/store.rs | 424 ++++++++++++++++++++++- 12 files changed, 899 insertions(+), 60 deletions(-) diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 50419378..7f22b21d 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -103,6 +103,8 @@ pub struct BackendWorkerCapabilitySummary { pub struct BackendWorkerSummary { pub runtime_id: String, pub worker_id: String, + #[serde(default)] + pub human_key: Option, pub host_id: String, #[serde(default)] pub display_name: String, diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 7dd923a9..d6b0f964 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -124,6 +124,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(7)?; Ok(TicketSummary { id: row.get(0)?, + human_key: None, slug: row.get(1)?, title: row.get(2)?, status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), @@ -926,6 +927,8 @@ impl TicketListQuery { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketRef { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_key: Option, pub slug: String, pub status: TicketStatus, } @@ -1540,6 +1543,8 @@ pub struct OrchestrationPlanRecord { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketMeta { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_key: Option, pub slug: String, pub title: String, pub status: ExtensibleTicketStatus, @@ -1563,6 +1568,8 @@ pub struct TicketMeta { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketSummary { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_key: Option, pub slug: String, pub title: String, pub status: ExtensibleTicketStatus, @@ -2669,6 +2676,9 @@ impl SqliteTicketBackend { let mut summaries = rows .collect::, _>>() .map_err(sqlite_err)?; + for summary in &mut summaries { + summary.human_key = Self::human_key_for(conn, &self.workspace_id, &summary.id)?; + } let has_more = summaries.len() > query.limit; summaries.truncate(query.limit); let next = has_more.then(|| { @@ -2752,6 +2762,7 @@ impl SqliteTicketBackend { updated_at, ) = row.map_err(sqlite_err)?; summaries.push(TicketSummary { + human_key: Self::human_key_for(conn, &self.workspace_id, &id)?, id, slug, title, @@ -2928,7 +2939,16 @@ impl SqliteTicketBackend { fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result { let query = id.as_query().to_string(); - let mut stmt = conn.prepare("SELECT ticket_id FROM typed_tickets WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2) ORDER BY ticket_id").map_err(sqlite_err)?; + let mut stmt = conn + .prepare( + "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 + ORDER BY 1", + ) + .map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id, query], |row| { row.get::<_, String>(0) @@ -2947,6 +2967,61 @@ impl SqliteTicketBackend { } } + fn human_key_for( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { + conn.query_row( + "SELECT human_key FROM workspace_resource_human_keys + WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_id = ?2", + params![workspace_id, ticket_id], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_err) + } + + fn allocate_human_key( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + allocated_at: &str, + ) -> Result { + if let Some(existing) = Self::human_key_for(conn, workspace_id, ticket_id)? { + return Ok(existing); + } + conn.execute( + "INSERT OR IGNORE INTO workspace_resource_human_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 + 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 + 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}"); + conn.execute( + "INSERT INTO workspace_resource_human_keys + (workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at) + VALUES (?1, 'ticket', ?2, ?3, ?4, ?5)", + params![workspace_id, ticket_id, sequence, human_key, allocated_at], + ) + .map_err(sqlite_err)?; + Ok(human_key) + } + fn ticket_exists(&self, conn: &Connection, id: &str) -> Result { Ok(conn .query_row( @@ -3090,6 +3165,7 @@ impl SqliteTicketBackend { let state_raw: String = row.get(12)?; Ok(TicketMeta { id: row.get(0)?, + human_key: None, slug: row.get(1)?, title: row.get(2)?, status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), @@ -3117,6 +3193,7 @@ impl SqliteTicketBackend { self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst); let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; + meta.human_key = Self::human_key_for(conn, &self.workspace_id, ticket_id)?; meta.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)?; @@ -3288,6 +3365,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)?; if !filter.matches_state(meta.workflow_state) { continue; } @@ -3437,6 +3515,7 @@ impl TicketBackend for SqliteTicketBackend { }; let meta = TicketMeta { id: id.clone(), + human_key: None, slug: input.slug.clone().unwrap_or_else(|| id.clone()), title: input.title, status, @@ -3473,7 +3552,7 @@ impl TicketBackend for SqliteTicketBackend { events: vec![TicketEvent { kind: TicketEventKind::Create, author: Some(author), - at: Some(now), + at: Some(now.clone()), status: None, from: None, to: None, @@ -3488,9 +3567,11 @@ impl TicketBackend for SqliteTicketBackend { relations: TicketRelationView::default(), resolution: None, }; + let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?; self.insert_ticket(conn, &ticket)?; Ok(TicketRef { id: id.clone(), + human_key: Some(human_key), slug: id, status: TicketStatus::Open, }) @@ -4137,6 +4218,7 @@ impl TicketBackend for LocalTicketBackend { atomic_write(&dir.join("thread.md"), thread.as_bytes())?; Ok(TicketRef { id: id.clone(), + human_key: None, slug: id, status: TicketStatus::Open, }) @@ -5174,6 +5256,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { }; TicketMeta { id: id.clone(), + human_key: None, slug: id, title: frontmatter.title.unwrap_or_default(), status, @@ -5198,6 +5281,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, slug: meta.slug, title: meta.title, status: meta.status, @@ -6806,6 +6890,7 @@ mod tests { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { TicketSummary { id: "000TEST".to_string(), + human_key: Some("T-1".to_string()), slug: "000TEST".to_string(), title: "Test Ticket".to_string(), status: ExtensibleTicketStatus::Open, @@ -7210,6 +7295,59 @@ state: planning ); } + #[test] + fn sqlite_human_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!(backend.show("T-1".into()).unwrap().meta.id, first.id); + let projection = backend.list_workspace_projection(100).unwrap(); + let projected_second = projection + .items + .iter() + .find(|item| item.summary.id == second.id) + .unwrap(); + assert_eq!(projected_second.summary.human_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.show("T-1".into()).unwrap().meta.id, other_first.id); + } + + #[test] + fn sqlite_human_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(); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let handles = (0..8) + .map(|index| { + let db_path = db_path.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let backend = SqliteTicketBackend::open(db_path, "workspace-a").unwrap(); + barrier.wait(); + backend + .create(NewTicket::new(format!("Ticket {index}"))) + .unwrap() + .human_key + .unwrap() + }) + }) + .collect::>(); + let mut keys = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + keys.sort_by_key(|key| key.trim_start_matches("T-").parse::().unwrap()); + assert_eq!(keys, (1..=8).map(|n| format!("T-{n}")).collect::>()); + } + #[test] fn sqlite_backend_persists_and_edits_ticket_target() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index ebbbf8f2..14e13bbc 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 4; +pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 5; #[derive(Clone, Copy)] struct Migration { @@ -37,6 +37,11 @@ const MIGRATIONS: &[Migration] = &[ name: "add_ticket_query_indexes", apply: add_ticket_query_indexes, }, + Migration { + version: 5, + name: "add_workspace_human_keys", + apply: add_workspace_human_keys, + }, ]; #[derive(Clone, Copy)] @@ -535,6 +540,57 @@ fn add_ticket_query_indexes(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +fn add_workspace_human_keys(connection: &Connection) -> Result<()> { + connection + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspace_resource_human_keys ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + human_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, human_key) + ); + CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind) + ); + + INSERT OR IGNORE INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at + ) + SELECT workspace_id, + 'ticket', + ticket_id, + ROW_NUMBER() OVER ( + PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC + ), + 'T-' || ROW_NUMBER() OVER ( + PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC + ), + COALESCE(created_at, updated_at) + FROM typed_tickets; + + INSERT INTO workspace_resource_human_key_counters ( + workspace_id, resource_kind, next_sequence + ) + SELECT workspace_id, 'ticket', MAX(sequence) + 1 + FROM workspace_resource_human_keys + WHERE resource_kind = 'ticket' + GROUP BY workspace_id + ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET + next_sequence = MAX(next_sequence, excluded.next_sequence); + "#, + ) + .map_err(sqlite_err) +} + fn add_column_if_missing( connection: &Connection, table: &str, @@ -869,10 +925,10 @@ mod tests { verify_sqlite_ticket_schema(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap(); - assert_eq!(versions.len(), 4); + assert_eq!(versions.len(), 5); assert_eq!( versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), - Some(&"add_ticket_query_indexes".to_string()) + Some(&"add_workspace_human_keys".to_string()) ); } @@ -959,6 +1015,54 @@ mod tests { assert_eq!(preserved, (1, 1, 1, 1, 1)); } + #[test] + fn v5_backfills_ticket_keys_by_creation_order_and_advances_counter() { + let connection = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&connection).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 ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit, created_at, updated_at + ) VALUES + ('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'), + ('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');" + ).unwrap(); + + migrate_sqlite_ticket_schema(&connection).unwrap(); + let keys = connection + .prepare( + "SELECT resource_id, human_key FROM workspace_resource_human_keys + WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket' + ORDER BY sequence", + ) + .unwrap() + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + keys, + vec![ + ("earlier".into(), "T-1".into()), + ("later".into(), "T-2".into()) + ] + ); + let next: i64 = connection + .query_row( + "SELECT next_sequence FROM workspace_resource_human_key_counters + WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(next, 3); + } + #[test] fn upgrades_legacy_schema_without_repository_target_columns() { let connection = Connection::open_in_memory().unwrap(); @@ -1017,7 +1121,7 @@ mod tests { .to_string() .contains("unsupported Ticket schema migration version 99") ); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6); } #[test] @@ -1157,6 +1261,6 @@ mod tests { let connection = Connection::open(database).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap(); - assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4); + assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5); } } diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 4e24968d..c4bd1292 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -317,6 +317,9 @@ 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), @@ -358,6 +361,7 @@ mod tests { BackendWorkerSummary { runtime_id: runtime_id.to_string(), worker_id: worker_id.to_string(), + human_key: None, host_id: "host".to_string(), label: "label".to_string(), display_name: "label".to_string(), diff --git a/crates/tui/src/dashboard/render.rs b/crates/tui/src/dashboard/render.rs index ce1d4208..7236b565 100644 --- a/crates/tui/src/dashboard/render.rs +++ b/crates/tui/src/dashboard/render.rs @@ -535,7 +535,12 @@ pub(super) fn ticket_detail_style(row: &PanelRow) -> Style { pub(super) fn panel_ticket_reference(row: &PanelRow) -> String { row.ticket .as_ref() - .map(|ticket| ticket.id.clone()) + .map(|ticket| { + ticket + .human_key + .clone() + .unwrap_or_else(|| ticket.id.clone()) + }) .unwrap_or_else(|| match &row.key { PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(), diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index 8c4bc5da..9a92ddfd 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -1737,6 +1737,7 @@ 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(); assert!(title_line.starts_with("▶ ")); assert!(detail_line.starts_with("│ meta ")); @@ -1746,7 +1747,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(row_id)); + assert!(detail_line.contains(human_key)); assert!(detail_line.contains("Gate: clear")); assert!(detail_line.contains("Action: Wait")); } @@ -1769,7 +1770,7 @@ fn panel_ticket_non_selected_rows_align_with_selected_marker_space() { let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; assert!(title_line.starts_with(" ready")); - assert!(detail_line.starts_with(" meta 00001KTTB479X")); + assert!(detail_line.starts_with(" meta T-1")); assert_eq!(display_column(&title_line, "ready"), state_start); assert_eq!( display_column(&title_line, "Long Ticket title"), @@ -1797,7 +1798,7 @@ fn panel_ticket_title_truncates_after_state_column() { assert_eq!(display_column(&title_line, "Very long Ticket"), title_start); assert!(title_line.ends_with('…')); assert_eq!(detail_line.width(), 42); - assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear")); + assert!(detail_line.starts_with(" meta T-1 · Gate: clear")); assert!(detail_line.ends_with('…')); } @@ -3259,6 +3260,7 @@ fn panel_test_ticket_row( ) -> PanelRow { let ticket = crate::workspace_panel::TicketPanelEntry { id: id.to_string(), + human_key: Some("T-1".to_string()), title: title.to_string(), priority: "P2".to_string(), workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index a43a15fd..dd1bc10a 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -253,6 +253,7 @@ impl NextUserAction { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketPanelEntry { pub(crate) id: String, + pub(crate) human_key: Option, pub(crate) title: String, pub(crate) priority: String, pub(crate) workflow_state: TicketWorkflowState, @@ -1063,6 +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(), slug: meta.slug.clone(), title: meta.title.clone(), status: meta.status.clone(), @@ -1238,6 +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(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index e5f2b82c..6263f767 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -20,12 +20,13 @@ use crate::records::{ ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, - TicketQueryResponse, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body, - truncate_body, validate_project_id, + TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage, + summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, + WorkspaceResourceKind, }; use crate::{Error, Result}; @@ -227,10 +228,24 @@ impl SqliteWorkspaceAuthority { self } - fn objective_record(&self, id: &str) -> Result { + fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result { self.store - .get_objective(&self.workspace_id, id)? - .ok_or_else(|| unknown_objective_error(id)) + .resource_human_key(&self.workspace_id, kind, resource_id)? + .ok_or_else(|| Error::Store(format!("missing human key for {resource_id}"))) + } + + fn objective_record(&self, reference: &str) -> Result { + let id = self + .store + .resolve_resource_reference( + &self.workspace_id, + WorkspaceResourceKind::Objective, + reference, + )? + .ok_or_else(|| unknown_objective_error(reference))?; + self.store + .get_objective(&self.workspace_id, &id)? + .ok_or_else(|| unknown_objective_error(reference)) } fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result { @@ -247,6 +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, title: ticket.title, state: ticket.state, }) @@ -288,6 +304,7 @@ impl SqliteWorkspaceAuthority { .unwrap_or("none") ); Ok(ObjectiveDetail { + human_key: self.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -718,12 +735,16 @@ impl SqliteWorkspaceAuthority { .store .list_objectives_for_ticket(&self.workspace_id, id, 1_000)? .into_iter() - .map(|objective| ObjectiveLinkSummary { - id: objective.objective_id, - title: objective.title, - state: objective.state, + .map(|objective| { + Ok::<_, Error>(ObjectiveLinkSummary { + human_key: self + .human_key(WorkspaceResourceKind::Objective, &objective.objective_id)?, + id: objective.objective_id, + title: objective.title, + state: objective.state, + }) }) - .collect::>(); + .collect::>>()?; let implementation_reports = ticket .events .iter() @@ -734,11 +755,20 @@ impl SqliteWorkspaceAuthority { let current_assignment = self .store .get_current_ticket_worker_assignment(&self.workspace_id, id)? - .map(|assignment| TicketAssignmentSummary { - assignment_id: assignment.assignment_id, - runtime_id: assignment.worker.runtime_id, - worker_id: assignment.worker.worker_id, - }); + .map(|assignment| { + let worker_human_key = self.store.resource_human_key( + &self.workspace_id, + WorkspaceResourceKind::Worker, + &assignment.worker.worker_id, + )?; + Ok::<_, Error>(TicketAssignmentSummary { + assignment_id: assignment.assignment_id, + runtime_id: assignment.worker.runtime_id, + worker_id: assignment.worker.worker_id, + worker_human_key, + }) + }) + .transpose()?; let merge_request = match self.merge_request_store.get(&self.workspace_id, id) { Ok(request) => { let current_subject_ref = request.selector_from.as_deref().and_then(|selector| { @@ -763,8 +793,41 @@ 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 + .meta + .human_key + .clone() + .or(self.store.resource_human_key( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + &ticket.meta.id, + )?) + .ok_or_else(|| Error::Store(format!("missing human 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( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + &relation.target, + )?; + } + for relation in &mut relations.incoming { + relation.source_human_key = self.store.resource_human_key( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + &relation.source_ticket, + )?; + } + for blocker in &mut relations.blockers { + blocker.blocking_human_key = self.store.resource_human_key( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + &blocker.blocking_ticket, + )?; + } Ok(TicketDetail { id: ticket.meta.id, + human_key, title: ticket.meta.title, state: ticket.meta.workflow_state.as_str().to_string(), readiness: ticket.meta.readiness, @@ -797,7 +860,7 @@ impl SqliteWorkspaceAuthority { .into_iter() .map(|artifact| artifact.relative_path.display().to_string()) .collect(), - relations: ticket.relations.into(), + relations, linked_objectives, implementation_reports, current_assignment, @@ -820,7 +883,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .map(|item| { let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - TicketSummary { + let human_key = item.summary.human_key.clone().ok_or_else(|| { + Error::Store(format!("missing human key for {}", item.summary.id)) + })?; + Ok::<_, Error>(TicketSummary { + human_key, id: item.summary.id, title: item.summary.title, state: item.summary.workflow_state.as_str().to_string(), @@ -831,9 +898,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority { workspace_action_priority: workspace_action_priority_name(projection.priority) .to_string(), record_source: "sqlite_yoi_ticket".to_string(), - } + }) }) - .collect(); + .collect::>>()?; Ok(ProjectRecordList { items, invalid_records: Vec::new(), @@ -874,7 +941,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .items .into_iter() .map(ticket_summary_from_sqlite_item) - .collect::>(); + .collect::>>()?; let next_cursor = page .next .map(|position| make_ticket_summary_cursor(&fingerprint, position)); @@ -913,7 +980,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let authoritative = self .ticket_backend .show(TicketIdOrSlug::Id(ticket_id.clone()))?; - let summary = ticket_summary_from_ticket(&authoritative); + let summary = ticket_summary_from_ticket(&authoritative)?; let authoritative_body = authoritative.document.body.clone(); let authoritative_events = authoritative.events.clone(); let detail = self.ticket_detail_from_ticket( @@ -987,6 +1054,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { .map(|link| link.ticket_id) .collect::>(); items.push(ObjectiveSummary { + human_key: self + .human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -1032,6 +1101,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { .collect::>(); let body_md = record.body_md.clone(); let objective = ObjectiveSummary { + human_key: self + .human_key(WorkspaceResourceKind::Objective, &record.objective_id)?, id: record.objective_id, title: record.title, state: record.state, @@ -2023,6 +2094,7 @@ fn ticket_query_item( } TicketQueryItem { id: summary.id, + human_key: summary.human_key, title: summary.title, state: summary.state, readiness: detail.readiness.clone(), @@ -2362,9 +2434,10 @@ fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> Memor } } -fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary { +fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result { let summary = ticket::TicketSummary { id: ticket.meta.id.clone(), + human_key: ticket.meta.human_key.clone(), slug: ticket.meta.slug.clone(), title: ticket.meta.title.clone(), status: ticket.meta.status.clone(), @@ -2384,9 +2457,15 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> TicketSummary { }) } -fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary { +fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result { let projection = project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - TicketSummary { + let human_key = item + .summary + .human_key + .clone() + .ok_or_else(|| Error::Store(format!("missing human key for {}", item.summary.id)))?; + Ok(TicketSummary { + human_key, id: item.summary.id, title: item.summary.title, state: item.summary.workflow_state.as_str().to_string(), @@ -2396,7 +2475,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary queued_at: item.summary.queued_at, workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(), record_source: "sqlite_yoi_ticket".to_string(), - } + }) } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c43d3b22..f02ac49f 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -246,6 +246,8 @@ pub struct WorkerCapabilitySummary { pub struct WorkerSummary { #[serde(flatten)] pub worker: RuntimeWorkerRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_key: Option, pub host_id: String, /// Human-readable display name. This is not identity and may be duplicated. pub display_name: String, @@ -1678,6 +1680,7 @@ impl EmbeddedWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), + human_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -1717,6 +1720,7 @@ impl EmbeddedWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), + human_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -2794,6 +2798,7 @@ impl RemoteWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), + human_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -2837,6 +2842,7 @@ impl RemoteWorkerRuntime { ); WorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), + human_key: None, host_id: self.host_id.clone(), display_name: display.display_name.clone(), label: display.display_name, @@ -4208,6 +4214,7 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { let host_id = host_id.into(); WorkerSummary { worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"), + human_key: None, host_id, display_name: "Worker runtime actions are not implemented".to_string(), label: "Worker runtime actions are not implemented".to_string(), @@ -4601,6 +4608,7 @@ mod tests { host_id: host_id.to_string(), workers: vec![WorkerSummary { worker: RuntimeWorkerRef::new(runtime_id, worker_id), + human_key: None, host_id: host_id.to_string(), display_name: label.to_string(), label: label.to_string(), diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index d27cd326..adda4325 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -31,6 +31,7 @@ pub struct InvalidProjectRecord { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketSummary { pub id: String, + pub human_key: String, pub title: String, pub state: String, pub priority: String, @@ -66,6 +67,7 @@ pub struct TicketListResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, + pub human_key: String, pub title: String, pub state: String, pub readiness: Option, @@ -121,6 +123,8 @@ pub struct TicketRelation { pub ticket_id: String, pub kind: String, pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_human_key: Option, pub note: Option, pub author: String, pub at: String, @@ -130,6 +134,8 @@ pub struct TicketRelation { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct DerivedTicketRelation { pub source_ticket: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_human_key: Option, pub inverse_kind: String, pub forward_kind: String, pub note: Option, @@ -141,6 +147,8 @@ pub struct DerivedTicketRelation { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketRelationBlocker { pub blocking_ticket: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocking_human_key: Option, pub reason_kind: String, pub relation_kind: String, pub note: Option, @@ -174,6 +182,7 @@ impl From for TicketRelationView { ticket_id: relation.ticket_id, kind: relation.kind.as_str().to_string(), target: relation.target, + target_human_key: None, note: relation.note, author: relation.author, at: relation.at, @@ -184,6 +193,7 @@ impl From for TicketRelationView { .into_iter() .map(|relation| DerivedTicketRelation { source_ticket: relation.source_ticket, + source_human_key: None, inverse_kind: relation.inverse_kind, forward_kind: relation.forward_kind.as_str().to_string(), note: relation.note, @@ -196,6 +206,7 @@ impl From for TicketRelationView { .into_iter() .map(|blocker| TicketRelationBlocker { blocking_ticket: blocker.blocking_ticket, + blocking_human_key: None, reason_kind: blocker.reason_kind, relation_kind: blocker.relation_kind.as_str().to_string(), note: blocker.note, @@ -231,6 +242,7 @@ pub struct QueryPage { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveLinkSummary { pub id: String, + pub human_key: String, pub title: String, pub state: String, } @@ -252,6 +264,8 @@ pub struct TicketAssignmentSummary { pub assignment_id: String, pub runtime_id: String, pub worker_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_human_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -313,6 +327,7 @@ pub struct TicketQueryRequest { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketQueryItem { pub id: String, + pub human_key: String, pub title: String, pub state: String, pub readiness: Option, @@ -399,6 +414,7 @@ pub struct ObjectiveEventDetail { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveLinkedTicketSummary { pub id: String, + pub human_key: String, pub title: String, pub state: String, } @@ -406,6 +422,7 @@ pub struct ObjectiveLinkedTicketSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, + pub human_key: String, pub title: String, pub state: String, pub created_at: Option, @@ -418,6 +435,7 @@ pub struct ObjectiveSummary { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveDetail { pub id: String, + pub human_key: String, pub title: String, pub state: String, pub revision: String, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2b65afed..2c2039f7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -109,7 +109,7 @@ use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, - WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, + WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -1638,6 +1638,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/workers", get(scoped_list_workers).post(scoped_create_workspace_worker), ) + .route( + "/api/w/{workspace_id}/workers/{worker_ref}", + get(scoped_get_workspace_worker), + ) .route( "/api/w/{workspace_id}/protocol/ws", get(scoped_workspace_protocol_ws), @@ -2464,6 +2468,12 @@ struct ScopedConfigBundlePath { bundle_id: String, } +#[derive(Debug, Deserialize)] +struct ScopedWorkspaceWorkerReferencePath { + workspace_id: String, + worker_ref: String, +} + #[derive(Debug, Deserialize)] struct ScopedRuntimeWorkerPath { workspace_id: String, @@ -6235,6 +6245,35 @@ async fn scoped_worker_remove_source_boundary( } } +async fn scoped_get_workspace_worker( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let worker_id = api + .store + .resolve_resource_reference( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + &path.worker_ref, + )? + .ok_or_else(|| Error::UnknownWorker { + worker: RuntimeWorkerRef::new("unknown", &path.worker_ref), + })?; + let workers = workers_response(api.clone())?; + workers + .items + .into_iter() + .find(|worker| worker.worker.worker_id == worker_id) + .map(Json) + .ok_or_else(|| { + Error::UnknownWorker { + worker: RuntimeWorkerRef::new("unknown", worker_id), + } + .into() + }) +} + async fn scoped_list_workers( State(api): State, AxumPath(path): AxumPath, @@ -9511,11 +9550,36 @@ struct WorkerShowProjection { updated_at: String, } +fn resolve_workspace_worker_reference( + api: &WorkspaceApi, + runtime_id: &str, + reference: &str, +) -> ApiResult { + let worker_id = api + .store + .resolve_resource_reference( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + reference, + )? + .ok_or_else(|| Error::UnknownWorker { + worker: RuntimeWorkerRef::new(runtime_id, reference), + })?; + let worker = RuntimeWorkerRef::new(runtime_id, worker_id); + let record = api + .store + .get_worker_registry(&api.config.workspace_id, &worker)? + .ok_or_else(|| Error::UnknownWorker { + worker: RuntimeWorkerRef::new(runtime_id, reference), + })?; + Ok(record.worker) +} + async fn get_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, ) -> ApiResult> { - let worker_ref = RuntimeWorkerRef::new(runtime_id, worker_id); + let worker_ref = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let worker = api .runtime .worker(&worker_ref) @@ -9528,17 +9592,20 @@ async fn get_runtime_worker( .store .list_workdir_registry(&api.config.workspace_id, 500)?; let updated_at = record.updated_at.clone(); - Ok(Json(WorkerShowProjection { - worker: merge_worker_registry_projection(Some(&worker), &record, links, &workdirs), - updated_at, - })) + let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs); + worker.human_key = api.store.resource_human_key( + &api.config.workspace_id, + WorkspaceResourceKind::Worker, + &worker_ref.worker_id, + )?; + Ok(Json(WorkerShowProjection { worker, updated_at })) } async fn restore_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, ) -> ApiResult> { - let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let mut result = api.restore_workspace_worker(&worker)?; if let Some(worker) = result.worker.as_ref() { let record = sync_worker_observation(&api, worker)?; @@ -10145,7 +10212,7 @@ async fn send_runtime_worker_input( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json, ) -> ApiResult> { - let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .send_input(&worker, request) @@ -10158,7 +10225,7 @@ async fn runtime_worker_completions( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json, ) -> ApiResult> { - let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .worker_completions(&worker, request) @@ -10171,7 +10238,7 @@ async fn stop_runtime_worker( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json, ) -> ApiResult> { - let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .stop_worker(&worker, request) @@ -10194,7 +10261,7 @@ async fn cancel_runtime_worker( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json, ) -> ApiResult> { - let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .cancel_worker(&worker, request) @@ -10631,12 +10698,18 @@ fn workers_response(api: WorkspaceApi) -> ApiResult WorkerSummary { WorkerSummary { worker: record.worker.clone(), + human_key: None, host_id: "backend-registry".to_string(), display_name: record.display_name.clone(), label: record.display_name.clone(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f11b3860..fd1c73ad 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -206,6 +206,11 @@ const MIGRATIONS: &[Migration] = &[ name: "promote Workspace Worker UUIDv7 identity", apply: promote_workspace_worker_uuid_identity, }, + Migration { + version: 38, + name: "add Workspace resource human keys", + apply: add_workspace_resource_human_keys, + }, ]; struct Migration { @@ -517,9 +522,46 @@ pub struct FlowSourceRevisionRecord { pub created_at: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceResourceKind { + Ticket, + Objective, + Worker, +} + +impl WorkspaceResourceKind { + fn as_str(self) -> &'static str { + match self { + Self::Ticket => "ticket", + Self::Objective => "objective", + Self::Worker => "worker", + } + } + + fn prefix(self) -> &'static str { + match self { + Self::Ticket => "T", + Self::Objective => "O", + Self::Worker => "W", + } + } +} + #[async_trait] pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; + fn resource_human_key( + &self, + workspace_id: &str, + kind: WorkspaceResourceKind, + resource_id: &str, + ) -> Result>; + fn resolve_resource_reference( + &self, + workspace_id: &str, + kind: WorkspaceResourceKind, + reference: &str, + ) -> Result>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; async fn get_trusted_runtime(&self, runtime_id: &str) -> Result>; @@ -1017,6 +1059,13 @@ impl SqliteWorkspaceStore { let worker_id = WorkerId::now_v7(); let now = chrono::Utc::now().to_rfc3339(); + allocate_resource_human_key( + &tx, + workspace_id, + WorkspaceResourceKind::Worker, + &worker_id.to_string(), + &now, + )?; tx.execute( "INSERT INTO worker_create_reservations(\ workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint,\ @@ -1142,6 +1191,63 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(current_schema_version) } + fn resource_human_key( + &self, + workspace_id: &str, + kind: WorkspaceResourceKind, + resource_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + "SELECT human_key FROM workspace_resource_human_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), + ) + .optional() + .map_err(Error::from) + }) + } + + fn resolve_resource_reference( + &self, + workspace_id: &str, + kind: WorkspaceResourceKind, + reference: &str, + ) -> Result> { + 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", + params![workspace_id, kind.as_str(), reference], + |row| row.get(0), + ) + .optional()? + { + return Ok(Some(resource_id)); + } + let exists = match kind { + WorkspaceResourceKind::Ticket => conn.query_row( + "SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2)", + params![workspace_id, reference], + |row| row.get::<_, i64>(0), + )?, + WorkspaceResourceKind::Objective => conn.query_row( + "SELECT EXISTS(SELECT 1 FROM objectives WHERE workspace_id = ?1 AND objective_id = ?2)", + params![workspace_id, reference], + |row| row.get::<_, i64>(0), + )?, + WorkspaceResourceKind::Worker => conn.query_row( + "SELECT EXISTS(SELECT 1 FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2)", + params![workspace_id, reference], + |row| row.get::<_, i64>(0), + )?, + }; + Ok((exists != 0).then(|| reference.to_string())) + }) + } + async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -1596,7 +1702,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> { self.with_conn(|conn| { - conn.execute( + let tx = conn.unchecked_transaction()?; + allocate_resource_human_key( + &tx, + &record.workspace_id, + WorkspaceResourceKind::Objective, + &record.objective_id, + &record.created_at, + )?; + tx.execute( r#"INSERT INTO objectives ( workspace_id, objective_id, title, state, body_md, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) @@ -1617,6 +1731,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.updated_at, ], )?; + tx.commit()?; Ok(()) }) } @@ -5469,6 +5584,135 @@ fn promote_workspace_worker_uuid_identity(conn: &Connection) -> Result<()> { Ok(()) } +fn allocate_resource_human_key( + conn: &Connection, + workspace_id: &str, + kind: WorkspaceResourceKind, + resource_id: &str, + allocated_at: &str, +) -> Result { + if let Some(existing) = conn + .query_row( + "SELECT human_key FROM workspace_resource_human_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), + ) + .optional()? + { + return Ok(existing); + } + conn.execute( + "INSERT OR IGNORE INTO workspace_resource_human_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 + 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 + WHERE workspace_id = ?1 AND resource_kind = ?2", + params![workspace_id, kind.as_str(), sequence + 1], + )?; + let human_key = format!("{}-{sequence}", kind.prefix()); + conn.execute( + "INSERT INTO workspace_resource_human_keys + (workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + workspace_id, + kind.as_str(), + resource_id, + sequence, + human_key, + allocated_at + ], + )?; + Ok(human_key) +} + +fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspace_resource_human_keys ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + human_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, human_key) + ); + CREATE TABLE IF NOT EXISTS workspace_resource_human_key_counters ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind) + ); + + INSERT OR IGNORE INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at + ) + SELECT workspace_id, + 'objective', + objective_id, + ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC), + 'O-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, objective_id ASC), + created_at + FROM objectives; + INSERT OR IGNORE INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at + ) + SELECT workspace_id, + 'worker', + worker_id, + ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC), + 'W-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, worker_id ASC), + created_at + FROM worker_registry; + + INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) + SELECT workspace_id, resource_kind, MAX(sequence) + 1 + FROM workspace_resource_human_keys + GROUP BY workspace_id, resource_kind + ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET + next_sequence = MAX(next_sequence, excluded.next_sequence); + "#, + )?; + if table_exists(conn, "typed_tickets")? { + conn.execute_batch( + r#" + INSERT OR IGNORE INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at + ) + SELECT workspace_id, + 'ticket', + ticket_id, + ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC), + 'T-' || ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC), + created_at + FROM typed_tickets; + INSERT INTO workspace_resource_human_key_counters ( + workspace_id, resource_kind, next_sequence + ) + SELECT workspace_id, 'ticket', MAX(sequence) + 1 + FROM workspace_resource_human_keys + WHERE resource_kind = 'ticket' + GROUP BY workspace_id + ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET + next_sequence = MAX(next_sequence, excluded.next_sequence); + "#, + )?; + } + Ok(()) +} + fn remove_worker_control_delegation_authority(conn: &Connection) -> Result<()> { let mut statement = conn.prepare("SELECT workspace_id, grant_id, permissions_json FROM worker_control_grants")?; @@ -6125,6 +6369,87 @@ mod tests { .unwrap(); } + #[test] + fn v38_backfills_workspace_scoped_objective_and_worker_human_keys() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 37).unwrap(); + conn.execute( + "INSERT INTO workspaces(workspace_id, display_name, state, created_at, updated_at) + VALUES ('workspace-a', 'Workspace A', 'active', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + [], + ).unwrap(); + for (id, created_at) in [ + ("objective-later", "2026-01-02T00:00:00Z"), + ("objective-earlier", "2026-01-01T00:00:00Z"), + ] { + conn.execute( + "INSERT INTO objectives(workspace_id, objective_id, title, body_md, state, created_at, updated_at) + VALUES ('workspace-a', ?1, ?1, '', 'active', ?2, ?2)", + params![id, created_at], + ).unwrap(); + } + for (id, created_at) in [ + ( + "019b57c8-5c00-7000-8000-000000000002", + "2026-01-02T00:00:00Z", + ), + ( + "019b5280-0000-7000-8000-000000000001", + "2026-01-01T00:00:00Z", + ), + ] { + conn.execute( + "INSERT INTO worker_registry(workspace_id, worker_id, runtime_id, display_name, retention_state, created_at, updated_at) + VALUES ('workspace-a', ?1, 'runtime-a', ?1, 'normal', ?2, ?2)", + params![id, created_at], + ).unwrap(); + } + + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + let mut statement = conn + .prepare( + "SELECT resource_kind, resource_id, human_key FROM workspace_resource_human_keys + ORDER BY resource_kind, sequence", + ) + .unwrap(); + let keys = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + keys, + vec![ + ("objective".into(), "objective-earlier".into(), "O-1".into()), + ("objective".into(), "objective-later".into(), "O-2".into()), + ( + "worker".into(), + "019b5280-0000-7000-8000-000000000001".into(), + "W-1".into() + ), + ( + "worker".into(), + "019b57c8-5c00-7000-8000-000000000002".into(), + "W-2".into() + ), + ] + ); + assert_eq!(current_schema_version(&conn).unwrap(), 38); + let foreign_key_error: Option = conn + .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) + .optional() + .unwrap(); + assert!(foreign_key_error.is_none()); + } + #[test] fn startup_fails_closed_when_current_ticket_schema_has_drifted() { let conn = Connection::open_in_memory().unwrap(); @@ -6241,7 +6566,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 37); + assert_eq!(current_schema_version(&conn).unwrap(), 38); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -6359,7 +6684,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 37); + assert_eq!(current_schema_version(&conn).unwrap(), 38); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -6392,7 +6717,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 37); + assert_eq!(current_schema_version(&conn).unwrap(), 38); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -6459,7 +6784,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 37); + assert_eq!(current_schema_version(&conn).unwrap(), 38); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -6639,7 +6964,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(), 37); + assert_eq!(store.schema_version().await.unwrap(), 38); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -6656,13 +6981,59 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 37); + assert_eq!(reopened.schema_version().await.unwrap(), 38); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) ); } + #[tokio::test] + async fn objective_creation_allocates_and_resolves_workspace_human_key() { + let dir = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-a".into(), + owner_account_id: None, + display_name: "Workspace A".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + store + .upsert_objective(&ObjectiveRecord { + workspace_id: "workspace-a".into(), + objective_id: "objective-internal".into(), + title: "Ship it".into(), + state: "active".into(), + body_md: String::new(), + created_at: "2".into(), + updated_at: "2".into(), + }) + .unwrap(); + assert_eq!( + store + .resource_human_key( + "workspace-a", + WorkspaceResourceKind::Objective, + "objective-internal" + ) + .unwrap() + .as_deref(), + Some("O-1") + ); + assert_eq!( + store + .resolve_resource_reference("workspace-a", WorkspaceResourceKind::Objective, "O-1") + .unwrap() + .as_deref(), + Some("objective-internal") + ); + } + #[tokio::test] async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() { let dir = tempfile::tempdir().unwrap(); @@ -6697,6 +7068,37 @@ INSERT INTO workdir_registry ( .reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:different") .is_err() ); + assert_eq!( + store + .resource_human_key( + "workspace-a", + WorkspaceResourceKind::Worker, + &reserved.to_string() + ) + .unwrap() + .as_deref(), + Some("W-1") + ); + assert_eq!( + store + .resolve_resource_reference("workspace-a", WorkspaceResourceKind::Worker, "W-1") + .unwrap(), + Some(reserved.to_string()) + ); + let second = store + .reserve_worker_create("workspace-a", "arcadia", "operation-2", "sha256:two") + .unwrap(); + assert_eq!( + store + .resource_human_key( + "workspace-a", + WorkspaceResourceKind::Worker, + &second.to_string() + ) + .unwrap() + .as_deref(), + Some("W-2") + ); store .complete_worker_create_reservation("workspace-a", reserved) .unwrap(); @@ -7255,7 +7657,7 @@ INSERT INTO workdir_registry ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 37); + assert_eq!(store.schema_version().await.unwrap(), 38); store .with_conn(|conn| { @@ -7444,7 +7846,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(), 37); + assert_eq!(store.schema_version().await.unwrap(), 38); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -7510,7 +7912,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(), 37); + assert_eq!(store.schema_version().await.unwrap(), 38); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -7901,7 +8303,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(), 37); + assert_eq!(store.schema_version().await.unwrap(), 38); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(),