feat: add workspace resource human keys

This commit is contained in:
2026-08-20 02:17:00 +09:00
parent bb8eda379f
commit 1ca36d6b66
12 changed files with 899 additions and 60 deletions
+2
View File
@@ -103,6 +103,8 @@ pub struct BackendWorkerCapabilitySummary {
pub struct BackendWorkerSummary {
pub runtime_id: String,
pub worker_id: String,
#[serde(default)]
pub human_key: Option<String>,
pub host_id: String,
#[serde(default)]
pub display_name: String,
+140 -2
View File
@@ -124,6 +124,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,
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<String>,
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<String>,
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<String>,
pub slug: String,
pub title: String,
pub status: ExtensibleTicketStatus,
@@ -2669,6 +2676,9 @@ impl SqliteTicketBackend {
let mut summaries = rows
.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)?;
}
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<String> {
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<Option<String>> {
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<String> {
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<bool> {
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<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.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::<Vec<_>>();
let mut keys = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>();
keys.sort_by_key(|key| key.trim_start_matches("T-").parse::<u64>().unwrap());
assert_eq!(keys, (1..=8).map(|n| format!("T-{n}")).collect::<Vec<_>>());
}
#[test]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
+109 -5
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 = 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::<std::result::Result<Vec<_>, _>>()
.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);
}
}
+4
View File
@@ -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(),
+6 -1
View File
@@ -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(),
+5 -3
View File
@@ -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),
+3
View File
@@ -253,6 +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) 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,
+104 -25
View File
@@ -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<ObjectiveRecord> {
fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
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<ObjectiveRecord> {
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<ObjectiveDetail> {
@@ -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::<Vec<_>>();
.collect::<Result<Vec<_>>>()?;
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::<Result<Vec<_>>>()?;
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::<Vec<_>>();
.collect::<Result<Vec<_>>>()?;
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::<Vec<_>>();
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::<Vec<_>>();
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<TicketSummary> {
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<TicketSummary> {
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)]
+8
View File
@@ -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<String>,
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<String>) -> 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(),
+18
View File
@@ -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<String>,
@@ -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<String>,
pub note: Option<String>,
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<String>,
pub inverse_kind: String,
pub forward_kind: String,
pub note: Option<String>,
@@ -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<String>,
pub reason_kind: String,
pub relation_kind: String,
pub note: Option<String>,
@@ -174,6 +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,
note: relation.note,
author: relation.author,
at: relation.at,
@@ -184,6 +193,7 @@ impl From<ticket::TicketRelationView> 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<ticket::TicketRelationView> 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<String>,
}
#[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<String>,
@@ -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<String>,
@@ -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,
+87 -13
View File
@@ -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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>,
) -> ApiResult<Json<WorkerSummary>> {
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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -9511,11 +9550,36 @@ struct WorkerShowProjection {
updated_at: String,
}
fn resolve_workspace_worker_reference(
api: &WorkspaceApi,
runtime_id: &str,
reference: &str,
) -> ApiResult<RuntimeWorkerRef> {
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<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<WorkerShowProjection>> {
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<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<WorkerRestoreResponse>> {
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<WorkerInputRequest>,
) -> ApiResult<Json<WorkerInputResult>> {
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<WorkerCompletionsRequest>,
) -> ApiResult<Json<WorkerCompletionsResult>> {
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<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> {
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<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> {
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<RuntimeListResponse<WorkerSu
let links = api
.store
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
items.push(merge_worker_registry_projection(
let mut summary = merge_worker_registry_projection(
observed.get(&record.worker),
&record,
links,
&workdir_records,
));
);
summary.human_key = api.store.resource_human_key(
&api.config.workspace_id,
WorkspaceResourceKind::Worker,
&record.worker.worker_id,
)?;
items.push(summary);
}
Ok(RuntimeListResponse {
workspace_id: api.config.workspace_id,
@@ -11504,6 +11577,7 @@ fn record_worker_summary(
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> 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(),
+413 -11
View File
@@ -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<i64>;
fn resource_human_key(
&self,
workspace_id: &str,
kind: WorkspaceResourceKind,
resource_id: &str,
) -> Result<Option<String>>;
fn resolve_resource_reference(
&self,
workspace_id: &str,
kind: WorkspaceResourceKind,
reference: &str,
) -> Result<Option<String>>;
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
async fn get_trusted_runtime(&self, runtime_id: &str) -> Result<Option<TrustedRuntimeRecord>>;
@@ -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<Option<String>> {
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<Option<String>> {
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<String> {
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::<std::result::Result<Vec<_>, _>>()
.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<String> = 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(),