14 Commits
58 changed files with 4809 additions and 443 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tar = "0.4" tar = "0.4"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["backup", "bundled"] }
ring = "0.17.14" ring = "0.17.14"
sha2 = "0.11" sha2 = "0.11"
tempfile = "3.27" tempfile = "3.27"
+2
View File
@@ -103,6 +103,8 @@ pub struct BackendWorkerCapabilitySummary {
pub struct BackendWorkerSummary { pub struct BackendWorkerSummary {
pub runtime_id: String, pub runtime_id: String,
pub worker_id: String, pub worker_id: String,
#[serde(default)]
pub human_key: Option<String>,
pub host_id: String, pub host_id: String,
#[serde(default)] #[serde(default)]
pub display_name: String, pub display_name: String,
+29
View File
@@ -530,6 +530,15 @@ pub enum Event {
revision: u64, revision: u64,
event: Box<Event>, event: Box<Event>,
}, },
/// Terminal removal fence for one parent-owned Internal Worker session.
///
/// Clients discard the matching child and descendants, then ignore later
/// nested events for this identity until an authoritative snapshot replaces
/// the projection.
InternalWorkerRemoved {
worker: InternalWorkerRef,
revision: u64,
},
/// Server-side segment log rotated to a fresh `SegmentStart`. /// Server-side segment log rotated to a fresh `SegmentStart`.
/// ///
/// Fires on compaction and on auto-fork when the store head drifts /// Fires on compaction and on auto-fork when the store head drifts
@@ -1802,6 +1811,26 @@ mod tests {
} }
} }
#[test]
fn internal_worker_removal_roundtrip_preserves_terminal_fence() {
let event = Event::InternalWorkerRemoved {
worker: InternalWorkerRef {
session_id: "session-1".into(),
name: "research".into(),
parent_session_id: Some("parent-session".into()),
kind: InternalWorkerKind::SubWorker,
},
revision: 8,
};
let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Event::InternalWorkerRemoved { worker, revision }
if worker.session_id == "session-1" && revision == 8
));
}
#[test] #[test]
fn legacy_snapshot_defaults_internal_workers_to_empty() { fn legacy_snapshot_defaults_internal_workers_to_empty() {
let snapshot: Event = serde_json::from_value(serde_json::json!({ let snapshot: Event = serde_json::from_value(serde_json::json!({
+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)?; let workflow_state = row.get::<_, String>(7)?;
Ok(TicketSummary { Ok(TicketSummary {
id: row.get(0)?, id: row.get(0)?,
human_key: None,
slug: row.get(1)?, slug: row.get(1)?,
title: row.get(2)?, title: row.get(2)?,
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
@@ -926,6 +927,8 @@ impl TicketListQuery {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketRef { pub struct TicketRef {
pub id: String, pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String, pub slug: String,
pub status: TicketStatus, pub status: TicketStatus,
} }
@@ -1540,6 +1543,8 @@ pub struct OrchestrationPlanRecord {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketMeta { pub struct TicketMeta {
pub id: String, pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String, pub slug: String,
pub title: String, pub title: String,
pub status: ExtensibleTicketStatus, pub status: ExtensibleTicketStatus,
@@ -1563,6 +1568,8 @@ pub struct TicketMeta {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketSummary { pub struct TicketSummary {
pub id: String, pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub slug: String, pub slug: String,
pub title: String, pub title: String,
pub status: ExtensibleTicketStatus, pub status: ExtensibleTicketStatus,
@@ -2669,6 +2676,9 @@ impl SqliteTicketBackend {
let mut summaries = rows let mut summaries = rows
.collect::<std::result::Result<Vec<_>, _>>() .collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?; .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; let has_more = summaries.len() > query.limit;
summaries.truncate(query.limit); summaries.truncate(query.limit);
let next = has_more.then(|| { let next = has_more.then(|| {
@@ -2752,6 +2762,7 @@ impl SqliteTicketBackend {
updated_at, updated_at,
) = row.map_err(sqlite_err)?; ) = row.map_err(sqlite_err)?;
summaries.push(TicketSummary { summaries.push(TicketSummary {
human_key: Self::human_key_for(conn, &self.workspace_id, &id)?,
id, id,
slug, slug,
title, title,
@@ -2928,7 +2939,16 @@ impl SqliteTicketBackend {
fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> { fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result<String> {
let query = id.as_query().to_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 let rows = stmt
.query_map(params![self.workspace_id, query], |row| { .query_map(params![self.workspace_id, query], |row| {
row.get::<_, String>(0) 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> { fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
Ok(conn Ok(conn
.query_row( .query_row(
@@ -3090,6 +3165,7 @@ impl SqliteTicketBackend {
let state_raw: String = row.get(12)?; let state_raw: String = row.get(12)?;
Ok(TicketMeta { Ok(TicketMeta {
id: row.get(0)?, id: row.get(0)?,
human_key: None,
slug: row.get(1)?, slug: row.get(1)?,
title: row.get(2)?, title: row.get(2)?,
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), 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); 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"#, 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()))?; 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.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
meta.risk_flags = meta.risk_flags =
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?; self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
@@ -3288,6 +3365,7 @@ impl SqliteTicketBackend {
let mut summaries = Vec::new(); let mut summaries = Vec::new();
for row in rows { for row in rows {
let mut meta = row.map_err(sqlite_err)?; 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) { if !filter.matches_state(meta.workflow_state) {
continue; continue;
} }
@@ -3437,6 +3515,7 @@ impl TicketBackend for SqliteTicketBackend {
}; };
let meta = TicketMeta { let meta = TicketMeta {
id: id.clone(), id: id.clone(),
human_key: None,
slug: input.slug.clone().unwrap_or_else(|| id.clone()), slug: input.slug.clone().unwrap_or_else(|| id.clone()),
title: input.title, title: input.title,
status, status,
@@ -3473,7 +3552,7 @@ impl TicketBackend for SqliteTicketBackend {
events: vec![TicketEvent { events: vec![TicketEvent {
kind: TicketEventKind::Create, kind: TicketEventKind::Create,
author: Some(author), author: Some(author),
at: Some(now), at: Some(now.clone()),
status: None, status: None,
from: None, from: None,
to: None, to: None,
@@ -3488,9 +3567,11 @@ impl TicketBackend for SqliteTicketBackend {
relations: TicketRelationView::default(), relations: TicketRelationView::default(),
resolution: None, resolution: None,
}; };
let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?;
self.insert_ticket(conn, &ticket)?; self.insert_ticket(conn, &ticket)?;
Ok(TicketRef { Ok(TicketRef {
id: id.clone(), id: id.clone(),
human_key: Some(human_key),
slug: id, slug: id,
status: TicketStatus::Open, status: TicketStatus::Open,
}) })
@@ -4137,6 +4218,7 @@ impl TicketBackend for LocalTicketBackend {
atomic_write(&dir.join("thread.md"), thread.as_bytes())?; atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
Ok(TicketRef { Ok(TicketRef {
id: id.clone(), id: id.clone(),
human_key: None,
slug: id, slug: id,
status: TicketStatus::Open, status: TicketStatus::Open,
}) })
@@ -5174,6 +5256,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
}; };
TicketMeta { TicketMeta {
id: id.clone(), id: id.clone(),
human_key: None,
slug: id, slug: id,
title: frontmatter.title.unwrap_or_default(), title: frontmatter.title.unwrap_or_default(),
status, status,
@@ -5198,6 +5281,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
TicketSummary { TicketSummary {
id: meta.id, id: meta.id,
human_key: meta.human_key,
slug: meta.slug, slug: meta.slug,
title: meta.title, title: meta.title,
status: meta.status, status: meta.status,
@@ -6806,6 +6890,7 @@ mod tests {
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary { TicketSummary {
id: "000TEST".to_string(), id: "000TEST".to_string(),
human_key: Some("T-1".to_string()),
slug: "000TEST".to_string(), slug: "000TEST".to_string(),
title: "Test Ticket".to_string(), title: "Test Ticket".to_string(),
status: ExtensibleTicketStatus::Open, 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] #[test]
fn sqlite_backend_persists_and_edits_ticket_target() { fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap(); 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 MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32; 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)] #[derive(Clone, Copy)]
struct Migration { struct Migration {
@@ -37,6 +37,11 @@ const MIGRATIONS: &[Migration] = &[
name: "add_ticket_query_indexes", name: "add_ticket_query_indexes",
apply: 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)] #[derive(Clone, Copy)]
@@ -535,6 +540,57 @@ fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
.map_err(sqlite_err) .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( fn add_column_if_missing(
connection: &Connection, connection: &Connection,
table: &str, table: &str,
@@ -869,10 +925,10 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap(); verify_sqlite_ticket_schema(&connection).unwrap();
let versions = load_applied_migrations(&connection).unwrap(); let versions = load_applied_migrations(&connection).unwrap();
assert_eq!(versions.len(), 4); assert_eq!(versions.len(), 5);
assert_eq!( assert_eq!(
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION), 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)); 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] #[test]
fn upgrades_legacy_schema_without_repository_target_columns() { fn upgrades_legacy_schema_without_repository_target_columns() {
let connection = Connection::open_in_memory().unwrap(); let connection = Connection::open_in_memory().unwrap();
@@ -1017,7 +1121,7 @@ mod tests {
.to_string() .to_string()
.contains("unsupported Ticket schema migration version 99") .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] #[test]
@@ -1157,6 +1261,6 @@ mod tests {
let connection = Connection::open(database).unwrap(); let connection = Connection::open(database).unwrap();
verify_sqlite_ticket_schema(&connection).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);
} }
} }
+10 -3
View File
@@ -72,13 +72,20 @@ impl Tool for EditTool {
}) })
.await .await
.map_err(ToolsError::from)?; .map_err(ToolsError::from)?;
self.tracker.record_workdir_hash(&path, result.content_hash); let replacements = result.replacements;
self.tracker.record_workdir_edit(
&path,
result.content_hash,
replacements,
params.new_string.lines().count(),
params.old_string.lines().count(),
);
let summary = format!( let summary = format!(
"Edited {} ({} replacement{})", "Edited {} ({} replacement{})",
path, path,
result.replacements, replacements,
if result.replacements == 1 { "" } else { "s" } if replacements == 1 { "" } else { "s" }
); );
let preview = make_preview(&params.new_string, &params.new_string); let preview = make_preview(&params.new_string, &params.new_string);
+1 -1
View File
@@ -27,7 +27,7 @@ pub use error::ToolsError;
pub use glob::glob_tool; pub use glob::glob_tool;
pub use grep::grep_tool; pub use grep::grep_tool;
pub use read::read_tool; pub use read::read_tool;
pub use tracker::Tracker; pub use tracker::{ChangeStat, Tracker};
pub use view_image::view_image_tool; pub use view_image::view_image_tool;
pub use web::{web_fetch_tool, web_search_tool}; pub use web::{web_fetch_tool, web_search_tool};
pub use write::write_tool; pub use write::write_tool;
+90 -2
View File
@@ -119,12 +119,22 @@ fn normalize_path_lexically(path: &Path) -> PathBuf {
normalized normalized
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ChangeStat {
pub added: u64,
pub deleted: u64,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Inner { struct Inner {
/// Hash of each file's last observed contents, keyed by canonical path. /// Hash of each file's last observed contents, keyed by canonical path.
hashes: HashMap<PathBuf, ContentHash>, hashes: HashMap<PathBuf, ContentHash>,
/// Line count paired with observations that included the file content.
line_counts: HashMap<PathBuf, usize>,
/// LRU list of touched files. Front = most recently touched. /// LRU list of touched files. Front = most recently touched.
recency: VecDeque<PathBuf>, recency: VecDeque<PathBuf>,
/// Successful Write/Edit mutations attributed to this session's tools.
change_stat: ChangeStat,
} }
/// Canonical-path keyed tracker of file observations and their recency. /// Canonical-path keyed tracker of file observations and their recency.
@@ -187,8 +197,27 @@ impl Tracker {
} }
} }
pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, bytes: &[u8]) { pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, content: &[u8]) {
self.record_workdir_hash(path, hash_bytes(bytes)); let key = PathBuf::from(path.as_str());
let hash = hash_bytes(content);
let line_count = String::from_utf8_lossy(content).lines().count();
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.line_counts.insert(key.clone(), line_count);
inner.hashes.insert(key.clone(), hash);
inner.recency.retain(|candidate| candidate != &key);
inner.recency.push_front(key);
if inner.recency.len() > RECENCY_CAPACITY {
inner.recency.pop_back();
}
}
pub fn observed_workdir_line_count(&self, path: &workdir::WorkdirPath) -> Option<usize> {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.line_counts
.get(Path::new(path.as_str()))
.copied()
} }
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) { pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
@@ -202,6 +231,50 @@ impl Tracker {
} }
} }
/// Record a successful, session-attributable source mutation.
///
/// Callers supply line counts derived from the exact replacement accepted
/// by a Write/Edit tool. Bash and external process mutations are excluded
/// because this tracker cannot attribute them to one tool operation
/// authoritatively.
pub fn record_change(&self, added: usize, deleted: usize) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64);
inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64);
}
pub fn record_workdir_edit(
&self,
path: &workdir::WorkdirPath,
hash: workdir::ContentHash,
replacements: usize,
added_lines_per_replacement: usize,
deleted_lines_per_replacement: usize,
) {
let added = added_lines_per_replacement.saturating_mul(replacements);
let deleted = deleted_lines_per_replacement.saturating_mul(replacements);
let key = PathBuf::from(path.as_str());
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64);
inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64);
if let Some(line_count) = inner.line_counts.get_mut(&key) {
*line_count = line_count.saturating_sub(deleted).saturating_add(added);
}
inner.hashes.insert(key.clone(), hash);
inner.recency.retain(|candidate| candidate != &key);
inner.recency.push_front(key);
if inner.recency.len() > RECENCY_CAPACITY {
inner.recency.pop_back();
}
}
pub fn change_stat(&self) -> ChangeStat {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.change_stat
}
pub fn expected_workdir_hash( pub fn expected_workdir_hash(
&self, &self,
path: &workdir::WorkdirPath, path: &workdir::WorkdirPath,
@@ -458,6 +531,21 @@ mod tests {
} }
} }
#[test]
fn change_stat_saturates_and_accumulates_tracked_mutations() {
let tracker = Tracker::new();
tracker.record_change(7, 3);
tracker.record_change(5, 2);
assert_eq!(
tracker.change_stat(),
ChangeStat {
added: 12,
deleted: 5,
}
);
}
#[tokio::test] #[tokio::test]
async fn mutation_guard_blocks_equivalent_paths_until_drop() { async fn mutation_guard_blocks_equivalent_paths_until_drop() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+3
View File
@@ -50,6 +50,7 @@ impl Tool for WriteTool {
Err(error) => return Err(ToolsError::from(error).into()), Err(error) => return Err(ToolsError::from(error).into()),
}; };
let old_line_count = self.tracker.observed_workdir_line_count(&path).unwrap_or(0);
let outcome = self let outcome = self
.session .session
.write(WriteRequest { .write(WriteRequest {
@@ -60,6 +61,8 @@ impl Tool for WriteTool {
.await .await
.map_err(ToolsError::from)?; .map_err(ToolsError::from)?;
self.tracker
.record_change(params.content.lines().count(), old_line_count);
self.tracker self.tracker
.record_workdir_content(&path, params.content.as_bytes()); .record_workdir_content(&path, params.content.as_bytes());
+112 -1
View File
@@ -1,4 +1,4 @@
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use std::path::Path; use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -283,6 +283,8 @@ pub struct App {
/// Presentation-only Internal Worker projections keyed by session identity. /// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate sub-panes and never mixed into `blocks`. /// They are rendered in separate sub-panes and never mixed into `blocks`.
pub internal_workers: Vec<InternalWorkerView>, pub internal_workers: Vec<InternalWorkerView>,
/// Terminal child-session fences, reset only by an authoritative snapshot.
removed_internal_workers: HashMap<String, u64>,
pub scroll: Scroll, pub scroll: Scroll,
pub mode: Mode, pub mode: Mode,
pub cache: FileCache, pub cache: FileCache,
@@ -361,6 +363,7 @@ impl App {
blocks: Vec::new(), blocks: Vec::new(),
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
removed_internal_workers: HashMap::new(),
scroll: Scroll::default(), scroll: Scroll::default(),
mode: Mode::Normal, mode: Mode::Normal,
cache: FileCache::new(), cache: FileCache::new(),
@@ -1318,6 +1321,9 @@ impl App {
revision, revision,
event, event,
} => self.apply_internal_worker_event(worker, revision, *event), } => self.apply_internal_worker_event(worker, revision, *event),
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
}
Event::Status { status } => { Event::Status { status } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.set_worker_status(status); self.set_worker_status(status);
@@ -2002,6 +2008,7 @@ impl App {
.into_iter() .into_iter()
.map(Self::internal_worker_view_from_snapshot) .map(Self::internal_worker_view_from_snapshot)
.collect(); .collect();
self.removed_internal_workers.clear();
} }
fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView { fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView {
@@ -2029,6 +2036,12 @@ impl App {
revision: u64, revision: u64,
event: Event, event: Event,
) { ) {
if self
.removed_internal_workers
.contains_key(&worker.session_id)
{
return;
}
let index = self let index = self
.internal_workers .internal_workers
.iter() .iter()
@@ -2051,6 +2064,26 @@ impl App {
let _ = target.app.handle_worker_event(event); let _ = target.app.handle_worker_event(event);
} }
fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) {
let Some(index) = self
.internal_workers
.iter()
.position(|candidate| candidate.worker.session_id == worker.session_id)
else {
self.removed_internal_workers
.entry(worker.session_id)
.and_modify(|current| *current = (*current).max(revision))
.or_insert(revision);
return;
};
if revision <= self.internal_workers[index].revision {
return;
}
self.internal_workers.remove(index);
self.removed_internal_workers
.insert(worker.session_id, revision);
}
fn restore_snapshot( fn restore_snapshot(
&mut self, &mut self,
entries: &[serde_json::Value], entries: &[serde_json::Value],
@@ -3542,6 +3575,84 @@ mod completion_flow_tests {
); );
} }
#[test]
fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
name: "child".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
let nested = InternalWorkerRef {
session_id: "grandchild-session".into(),
name: "grandchild".into(),
parent_session_id: Some("child-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 2,
event: Box::new(Event::InternalWorker {
worker: nested,
revision: 1,
event: Box::new(Event::TextDone {
text: "nested".into(),
}),
}),
});
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1);
app.handle_worker_event(Event::InternalWorkerRemoved {
worker: worker.clone(),
revision: 3,
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 4,
event: Box::new(Event::TextDone {
text: "late".into(),
}),
});
assert!(app.internal_workers.is_empty());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert!(app.internal_workers.is_empty());
assert!(app.removed_internal_workers.is_empty());
}
#[test]
fn stale_internal_worker_removal_keeps_newer_projection() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
name: "child".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 4,
event: Box::new(Event::TextDone {
text: "current".into(),
}),
});
app.handle_worker_event(Event::InternalWorkerRemoved {
worker,
revision: 3,
});
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 4);
}
#[test] #[test]
fn snapshot_authoritatively_replaces_internal_worker_views() { fn snapshot_authoritatively_replaces_internal_worker_views() {
let mut app = App::new("parent".into()); let mut app = App::new("parent".into());
+4
View File
@@ -317,6 +317,9 @@ fn state_style(state: &str) -> Style {
} }
fn short_worker_id(worker: &BackendWorkerSummary) -> String { fn short_worker_id(worker: &BackendWorkerSummary) -> String {
if let Some(human_key) = worker.human_key.as_ref() {
return human_key.clone();
}
format!( format!(
"{}:{}", "{}:{}",
short_text(&worker.runtime_id), short_text(&worker.runtime_id),
@@ -358,6 +361,7 @@ mod tests {
BackendWorkerSummary { BackendWorkerSummary {
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(), worker_id: worker_id.to_string(),
human_key: None,
host_id: "host".to_string(), host_id: "host".to_string(),
label: "label".to_string(), label: "label".to_string(),
display_name: "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 { pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
row.ticket row.ticket
.as_ref() .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 { .unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_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 state_start = 2;
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
let row_id = row.ticket.as_ref().unwrap().id.as_str(); 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!(title_line.starts_with(""));
assert!(detail_line.starts_with("│ meta ")); 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"), display_column(&title_line, "Workspace Dashboard composer targets"),
title_start title_start
); );
assert!(detail_line.contains(row_id)); assert!(detail_line.contains(human_key));
assert!(detail_line.contains("Gate: clear")); assert!(detail_line.contains("Gate: clear"));
assert!(detail_line.contains("Action: Wait")); 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; let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
assert!(title_line.starts_with(" ready")); 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, "ready"), state_start);
assert_eq!( assert_eq!(
display_column(&title_line, "Long Ticket title"), 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_eq!(display_column(&title_line, "Very long Ticket"), title_start);
assert!(title_line.ends_with('…')); assert!(title_line.ends_with('…'));
assert_eq!(detail_line.width(), 42); 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('…')); assert!(detail_line.ends_with('…'));
} }
@@ -3259,6 +3260,7 @@ fn panel_test_ticket_row(
) -> PanelRow { ) -> PanelRow {
let ticket = crate::workspace_panel::TicketPanelEntry { let ticket = crate::workspace_panel::TicketPanelEntry {
id: id.to_string(), id: id.to_string(),
human_key: Some("T-1".to_string()),
title: title.to_string(), title: title.to_string(),
priority: "P2".to_string(), priority: "P2".to_string(),
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning), workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
+3
View File
@@ -253,6 +253,7 @@ impl NextUserAction {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketPanelEntry { pub(crate) struct TicketPanelEntry {
pub(crate) id: String, pub(crate) id: String,
pub(crate) human_key: Option<String>,
pub(crate) title: String, pub(crate) title: String,
pub(crate) priority: String, pub(crate) priority: String,
pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state: TicketWorkflowState,
@@ -1063,6 +1064,7 @@ pub(crate) fn build_current_ticket_row(
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
TicketSummary { TicketSummary {
id: meta.id.clone(), id: meta.id.clone(),
human_key: meta.human_key.clone(),
slug: meta.slug.clone(), slug: meta.slug.clone(),
title: meta.title.clone(), title: meta.title.clone(),
status: meta.status.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 next_action = projection.next_action.map(next_user_action_from_workspace);
let entry = TicketPanelEntry { let entry = TicketPanelEntry {
id: summary.id.clone(), id: summary.id.clone(),
human_key: summary.human_key.clone(),
title: summary.title.clone(), title: summary.title.clone(),
priority: summary.priority.clone(), priority: summary.priority.clone(),
workflow_state: summary.workflow_state, workflow_state: summary.workflow_state,
+3 -3
View File
@@ -126,7 +126,7 @@ pub struct WorkingDirectoryCurrentObservation {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<u64>, pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>, pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
@@ -151,7 +151,7 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<u64>, pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>, pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
@@ -177,7 +177,7 @@ impl WorkingDirectorySummary {
current_ref: self.current_ref.clone(), current_ref: self.current_ref.clone(),
status: self.status.clone(), status: self.status.clone(),
cleanliness: self.cleanliness.clone(), cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id, primary_worker_id: self.primary_worker_id.clone(),
occupied_by: self.occupied_by.clone(), occupied_by: self.occupied_by.clone(),
} }
} }
+4 -4
View File
@@ -148,10 +148,10 @@ impl std::fmt::Debug for WorkspaceApiRef {
/// summarized without exposing raw host paths. /// summarized without exposing raw host paths.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateWorkerRequest { pub struct CreateWorkerRequest {
#[serde(default, skip_serializing_if = "Option::is_none")] /// Workspace-owned stable identity reserved before this request reaches a Runtime.
pub idempotency_key: Option<String>, pub worker_id: WorkerId,
#[serde(default, skip_serializing_if = "Option::is_none")] /// Canonical create-intent fingerprint bound to `worker_id` for retry recovery.
pub idempotency_fingerprint: Option<String>, pub create_fingerprint: String,
pub profile: ProfileSelector, pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>, pub display_name: Option<String>,
+896 -8
View File
@@ -2,7 +2,9 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::{WorkerId, WorkerRef}; use crate::identity::{
LegacyWorkerIdentityMapping, WorkerId, WorkerRef, legacy_worker_identity_mapping_digest,
};
use crate::management::{RuntimeBackendKind, RuntimeStatus}; use crate::management::{RuntimeBackendKind, RuntimeStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -11,10 +13,11 @@ use std::io::{BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 3;
const RUNTIME_FILE: &str = "runtime.json"; const RUNTIME_FILE: &str = "runtime.json";
const WORKERS_DIR: &str = "workers"; const WORKERS_DIR: &str = "workers";
const WORKER_FILE: &str = "worker.json"; const WORKER_FILE: &str = "worker.json";
const WORKER_METADATA_FILE: &str = "metadata.json";
const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl"; const LEGACY_OBSERVATIONS_FILE: &str = "observations.jsonl";
static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
@@ -24,6 +27,7 @@ static NEXT_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
pub struct FsRuntimeStoreOptions { pub struct FsRuntimeStoreOptions {
/// Root directory containing this Runtime's store data. /// Root directory containing this Runtime's store data.
pub root: PathBuf, pub root: PathBuf,
pub runtime_id: String,
pub display_name: Option<String>, pub display_name: Option<String>,
} }
@@ -31,14 +35,19 @@ impl FsRuntimeStoreOptions {
pub fn new(root: impl Into<PathBuf>) -> Self { pub fn new(root: impl Into<PathBuf>) -> Self {
Self { Self {
root: root.into(), root: root.into(),
runtime_id: "local".to_string(),
display_name: None, display_name: None,
} }
} }
pub fn with_runtime_id(mut self, runtime_id: impl Into<String>) -> Self {
self.runtime_id = runtime_id.into();
self
}
} }
/// Filesystem persistence boundary for one Worker Runtime state. /// Filesystem persistence boundary for one Worker Runtime state.
/// ///
/// Authority is Runtime-local typed Worker identity. Legacy pod paths, socket /// Authority is the Workspace-owned typed Worker identity. Legacy pod paths, socket
/// paths, and session paths are deliberately not part of the layout or lookup API. /// paths, and session paths are deliberately not part of the layout or lookup API.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct FsRuntimeStore { pub struct FsRuntimeStore {
@@ -46,6 +55,18 @@ pub struct FsRuntimeStore {
} }
impl FsRuntimeStore { impl FsRuntimeStore {
pub fn migration_plan(
options: &FsRuntimeStoreOptions,
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
plan_runtime_store_migration(&options.root, &options.runtime_id).map(|(plan, _)| plan)
}
pub fn migrate(
options: &FsRuntimeStoreOptions,
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
migrate_runtime_store(&options.root, &options.runtime_id)
}
pub fn root(&self) -> &Path { pub fn root(&self) -> &Path {
&self.root &self.root
} }
@@ -54,7 +75,10 @@ impl FsRuntimeStore {
&self.root &self.root
} }
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> { pub(crate) fn open_or_create(
root: PathBuf,
runtime_id: &str,
) -> Result<OpenedFsRuntimeStore, RuntimeError> {
let existed = root.exists(); let existed = root.exists();
if existed && !root.is_dir() { if existed && !root.is_dir() {
return Err(RuntimeError::StoreCorrupt { return Err(RuntimeError::StoreCorrupt {
@@ -82,6 +106,9 @@ impl FsRuntimeStore {
} }
} }
if existed {
migrate_runtime_store(&root, runtime_id)?;
}
let store = Self { root }; let store = Self { root };
let state = if existed { let state = if existed {
Some(store.load_runtime_state()?) Some(store.load_runtime_state()?)
@@ -241,7 +268,6 @@ pub(crate) struct OpenedFsRuntimeStore {
pub(crate) struct PersistedRuntimeState { pub(crate) struct PersistedRuntimeState {
pub(crate) display_name: Option<String>, pub(crate) display_name: Option<String>,
pub(crate) status: RuntimeStatus, pub(crate) status: RuntimeStatus,
pub(crate) next_worker_sequence: u64,
pub(crate) next_diagnostic_id: u64, pub(crate) next_diagnostic_id: u64,
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>, pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
pub(crate) workspace_owners: BTreeMap<String, String>, pub(crate) workspace_owners: BTreeMap<String, String>,
@@ -259,13 +285,877 @@ pub(crate) struct PersistedWorkerRecord {
pub(crate) working_directory: Option<WorkingDirectoryStatus>, pub(crate) working_directory: Option<WorkingDirectoryStatus>,
} }
fn runtime_io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError {
RuntimeError::StoreIo {
operation,
path: path.to_path_buf(),
source,
}
}
fn runtime_store_corrupt(path: &Path, message: String) -> RuntimeError {
RuntimeError::StoreCorrupt {
operation: "migrate Worker identity",
path: path.to_path_buf(),
message,
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsRuntimeStoreMigrationPlan {
pub current_schema_version: u32,
pub target_schema_version: u32,
pub migration_required: bool,
pub worker_count: usize,
pub migrated_worker_aggregate_count: usize,
pub migrated_diagnostic_worker_ref_count: usize,
pub cleared_diagnostic_worker_ref_count: usize,
pub mapping_digest: String,
pub mappings: Vec<LegacyWorkerIdentityMapping>,
pub excluded_ephemeral_paths: Vec<String>,
}
#[derive(Clone, Debug)]
struct PlannedRuntimeWorkerMigration {
worker_id: WorkerId,
source_dir: PathBuf,
workspace_id: Option<String>,
legacy_mapping: Option<LegacyWorkerIdentityMapping>,
}
fn plan_runtime_store_migration(
root: &Path,
runtime_id: &str,
) -> Result<
(
FsRuntimeStoreMigrationPlan,
Vec<PlannedRuntimeWorkerMigration>,
),
RuntimeError,
> {
let runtime_path = root.join(RUNTIME_FILE);
let bytes =
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
runtime_store_corrupt(
&runtime_path,
format!("decode Runtime state {}: {error}", runtime_path.display()),
)
})?;
let schema_version = document
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
runtime_store_corrupt(
&runtime_path,
"Runtime state is missing schema_version".to_string(),
)
})?;
let current_schema_version = u32::try_from(schema_version).map_err(|_| {
runtime_store_corrupt(
&runtime_path,
format!("Runtime store schema version {schema_version} is out of range"),
)
})?;
let staging = migration_sibling(root, "schema-v3-staging")?;
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
format!(
"unfinished Runtime migration artifact exists (staging={}, backup={})",
staging.display(),
backup.display()
),
));
}
if current_schema_version == SCHEMA_VERSION {
let plan = FsRuntimeStoreMigrationPlan {
current_schema_version,
target_schema_version: SCHEMA_VERSION,
migration_required: false,
worker_count: 0,
migrated_worker_aggregate_count: 0,
migrated_diagnostic_worker_ref_count: 0,
cleared_diagnostic_worker_ref_count: 0,
mapping_digest: legacy_worker_identity_mapping_digest(&[]),
mappings: Vec::new(),
excluded_ephemeral_paths: Vec::new(),
};
return Ok((plan, Vec::new()));
}
if !matches!(current_schema_version, 1 | 2) {
return Err(runtime_store_corrupt(
&runtime_path,
format!(
"unsupported Runtime store schema version {schema_version}; expected 1, 2, or {SCHEMA_VERSION}"
),
));
}
let excluded_ephemeral_paths = runtime_tree_exclusions(root)?;
let workers_dir = root.join(WORKERS_DIR);
let mut entries = fs::read_dir(&workers_dir)
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| runtime_io_error("read workers", &workers_dir, error))?;
entries.sort_by_key(|entry| entry.file_name());
let mut planned = Vec::with_capacity(entries.len());
let mut target_ids = std::collections::BTreeSet::new();
for entry in entries {
let source_dir = entry.path();
if !source_dir.is_dir() {
return Err(runtime_store_corrupt(
&source_dir,
"workers directory contains a non-directory entry".to_string(),
));
}
let name = entry.file_name();
let name = name.to_str().ok_or_else(|| {
runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
})?;
let snapshot_path = source_dir.join(WORKER_FILE);
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
let legacy_worker_id = name.parse::<u64>().map_err(|_| {
runtime_store_corrupt(
&source_dir,
format!("legacy Worker directory name must be numeric, found {name}"),
)
})?;
let workspace_id = snapshot
.get("workspace_id")
.and_then(serde_json::Value::as_str)
.filter(|workspace_id| !workspace_id.is_empty())
.ok_or_else(|| {
runtime_store_corrupt(
&snapshot_path,
"legacy Worker snapshot is missing workspace_id; unscoped Workers require an explicit migration disposition"
.to_string(),
)
})?
.to_string();
let worker_id =
WorkerId::from_legacy_binding(&workspace_id, runtime_id, legacy_worker_id);
let mapping = LegacyWorkerIdentityMapping {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.to_string(),
legacy_worker_id,
worker_id,
};
(worker_id, Some(workspace_id), Some(mapping))
} else {
let worker_id = name.parse::<WorkerId>().map_err(|_| {
runtime_store_corrupt(
&source_dir,
format!("schema-v2 Worker directory name must be a UUIDv7, found {name}"),
)
})?;
(worker_id, None, None)
};
if !target_ids.insert(worker_id) {
return Err(runtime_store_corrupt(
&snapshot_path,
format!("Worker identity maps to duplicate target {worker_id}"),
));
}
let target_dir = workers_dir.join(worker_id.to_string());
if target_dir.exists() && target_dir != source_dir {
return Err(runtime_store_corrupt(
&target_dir,
format!("target Worker directory {worker_id} already exists"),
));
}
planned.push(PlannedRuntimeWorkerMigration {
worker_id,
source_dir,
workspace_id,
legacy_mapping,
});
}
let mappings = planned
.iter()
.filter_map(|worker| worker.legacy_mapping.clone())
.collect::<Vec<_>>();
let mut migrated_worker_aggregate_count = 0;
for worker in &mut planned {
let snapshot_path = worker.source_dir.join(WORKER_FILE);
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
let migrated = migrate_worker_document(
snapshot,
current_schema_version,
worker.legacy_mapping.as_ref(),
&snapshot_path,
)?;
let snapshot = validate_migrated_worker_document(&migrated, &snapshot_path)?;
if snapshot.worker_id != worker.worker_id {
return Err(runtime_store_corrupt(
&snapshot_path,
format!(
"Worker snapshot id {} does not match directory identity {}",
snapshot.worker_id, worker.worker_id
),
));
}
worker.workspace_id = worker
.workspace_id
.clone()
.or(snapshot.workspace_id)
.or_else(|| {
snapshot
.request
.workspace_api
.map(|workspace_api| workspace_api.workspace_id)
});
let metadata_path = worker.source_dir.join(WORKER_METADATA_FILE);
if metadata_path.is_file() {
let metadata: serde_json::Value =
read_json(&metadata_path, "read Worker aggregate metadata")?;
let (_, migrated) =
migrate_worker_aggregate_document(metadata, worker, runtime_id, &metadata_path)?;
migrated_worker_aggregate_count += usize::from(migrated);
}
}
let (_, diagnostic_refs) =
migrate_runtime_document(document, current_schema_version, &mappings, &runtime_path)?;
let plan = FsRuntimeStoreMigrationPlan {
current_schema_version,
target_schema_version: SCHEMA_VERSION,
migration_required: true,
worker_count: planned.len(),
migrated_worker_aggregate_count,
migrated_diagnostic_worker_ref_count: diagnostic_refs.migrated,
cleared_diagnostic_worker_ref_count: diagnostic_refs.cleared,
mapping_digest: legacy_worker_identity_mapping_digest(&mappings),
mappings,
excluded_ephemeral_paths,
};
Ok((plan, planned))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct DiagnosticWorkerRefMigrationCounts {
migrated: usize,
cleared: usize,
}
fn migrate_v1_worker_document(
mut snapshot: serde_json::Value,
mapping: &LegacyWorkerIdentityMapping,
snapshot_path: &Path,
) -> Result<serde_json::Value, RuntimeError> {
let worker_id_text = mapping.worker_id.to_string();
let snapshot_object = snapshot.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot must be an object".to_string(),
)
})?;
snapshot_object.insert(
"schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION),
);
snapshot_object.insert(
"worker_id".to_string(),
serde_json::Value::String(worker_id_text.clone()),
);
snapshot_object
.get_mut("worker_ref")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot worker_ref must be an object".to_string(),
)
})?
.insert(
"worker_id".to_string(),
serde_json::Value::String(worker_id_text.clone()),
);
let request = snapshot_object
.get_mut("request")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot request must be an object".to_string(),
)
})?;
let fingerprint = request
.remove("idempotency_fingerprint")
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| {
format!(
"legacy:{}:{}:{}",
mapping.workspace_id, mapping.runtime_id, mapping.legacy_worker_id
)
});
request.remove("idempotency_key");
request.insert(
"worker_id".to_string(),
serde_json::Value::String(worker_id_text),
);
request.insert(
"create_fingerprint".to_string(),
serde_json::Value::String(fingerprint),
);
Ok(snapshot)
}
fn migrate_worker_document(
mut document: serde_json::Value,
source_schema_version: u32,
mapping: Option<&LegacyWorkerIdentityMapping>,
snapshot_path: &Path,
) -> Result<serde_json::Value, RuntimeError> {
if source_schema_version == 1 {
return migrate_v1_worker_document(
document,
mapping.ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"schema-v1 Worker migration is missing its identity mapping".to_string(),
)
})?,
snapshot_path,
);
}
let object = document.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot must be an object".to_string(),
)
})?;
object.insert(
"schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION),
);
Ok(document)
}
fn validate_migrated_worker_document(
document: &serde_json::Value,
snapshot_path: &Path,
) -> Result<WorkerSnapshot, RuntimeError> {
let snapshot: WorkerSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
runtime_store_corrupt(
snapshot_path,
format!("decode migrated Worker snapshot: {error}"),
)
})?;
snapshot.validate(snapshot_path)?;
Ok(snapshot)
}
fn runtime_worker_name(worker_id: WorkerId) -> String {
format!("worker-runtime-{worker_id}")
}
fn migrate_worker_aggregate_document(
mut document: serde_json::Value,
worker: &PlannedRuntimeWorkerMigration,
runtime_id: &str,
metadata_path: &Path,
) -> Result<(serde_json::Value, bool), RuntimeError> {
let expected_name = runtime_worker_name(worker.worker_id);
let metadata = document.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
metadata_path,
"Worker aggregate metadata must be an object".to_string(),
)
})?;
let actual_name = metadata
.get("worker_name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
runtime_store_corrupt(
metadata_path,
"Worker aggregate metadata is missing worker_name".to_string(),
)
})?
.to_string();
let migrated = actual_name != expected_name;
if migrated {
let legacy_worker_id = actual_name
.strip_prefix("worker-runtime-")
.and_then(|worker_id| worker_id.parse::<u64>().ok())
.ok_or_else(|| {
runtime_store_corrupt(
metadata_path,
format!(
"Worker aggregate identity {actual_name} is neither the expected UUID identity nor a legacy numeric identity"
),
)
})?;
let workspace_id = worker.workspace_id.as_deref().ok_or_else(|| {
runtime_store_corrupt(
metadata_path,
"legacy Worker aggregate identity has no Workspace binding".to_string(),
)
})?;
let mapped = WorkerId::from_legacy_binding(workspace_id, runtime_id, legacy_worker_id);
if mapped != worker.worker_id {
return Err(runtime_store_corrupt(
metadata_path,
format!(
"legacy Worker aggregate identity {actual_name} maps to {mapped}, expected {}",
worker.worker_id
),
));
}
}
if let Some(snapshot) = metadata
.get_mut("resolved_manifest_snapshot")
.filter(|snapshot| !snapshot.is_null())
{
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot.clone()).map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
)
})?;
if manifest.worker.name != actual_name {
return Err(runtime_store_corrupt(
metadata_path,
format!(
"Worker aggregate manifest identity {} does not match metadata identity {actual_name}",
manifest.worker.name
),
));
}
snapshot
.as_object_mut()
.and_then(|manifest| manifest.get_mut("worker"))
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
runtime_store_corrupt(
metadata_path,
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
)
})?
.insert(
"name".to_string(),
serde_json::Value::String(expected_name.clone()),
);
}
metadata.insert(
"worker_name".to_string(),
serde_json::Value::String(expected_name.clone()),
);
let metadata: session_store::WorkerMetadata = serde_json::from_value(document.clone())
.map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode migrated Worker aggregate metadata: {error}"),
)
})?;
if metadata.worker_name != expected_name {
return Err(runtime_store_corrupt(
metadata_path,
"migrated Worker aggregate identity does not match its Worker UUID".to_string(),
));
}
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot).map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode migrated Worker aggregate resolved manifest: {error}"),
)
})?;
if manifest.worker.name != expected_name {
return Err(runtime_store_corrupt(
metadata_path,
"migrated Worker aggregate manifest identity does not match its Worker UUID"
.to_string(),
));
}
}
Ok((document, migrated))
}
fn migrate_runtime_document(
mut document: serde_json::Value,
source_schema_version: u32,
mappings: &[LegacyWorkerIdentityMapping],
runtime_path: &Path,
) -> Result<(serde_json::Value, DiagnosticWorkerRefMigrationCounts), RuntimeError> {
let mapped_worker_ids = mappings
.iter()
.map(|mapping| (mapping.legacy_worker_id, mapping.worker_id))
.collect::<BTreeMap<_, _>>();
let mut counts = DiagnosticWorkerRefMigrationCounts::default();
if source_schema_version == 1
&& let Some(diagnostics) = document.get_mut("diagnostics")
{
let diagnostics = diagnostics.as_array_mut().ok_or_else(|| {
runtime_store_corrupt(
runtime_path,
"Runtime snapshot diagnostics must be an array".to_string(),
)
})?;
for (index, diagnostic) in diagnostics.iter_mut().enumerate() {
let diagnostic = diagnostic.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
runtime_path,
format!("Runtime diagnostic {index} must be an object"),
)
})?;
let Some(worker_ref) = diagnostic.get("worker_ref") else {
continue;
};
if worker_ref.is_null() {
continue;
}
let legacy_worker_id = worker_ref
.as_object()
.and_then(|worker_ref| worker_ref.get("worker_id"))
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
runtime_store_corrupt(
runtime_path,
format!(
"Runtime diagnostic {index} worker_ref.worker_id must be an unsigned legacy Worker id"
),
)
})?;
if let Some(worker_id) = mapped_worker_ids.get(&legacy_worker_id) {
diagnostic
.get_mut("worker_ref")
.and_then(serde_json::Value::as_object_mut)
.expect("validated diagnostic Worker reference")
.insert(
"worker_id".to_string(),
serde_json::Value::String(worker_id.to_string()),
);
counts.migrated += 1;
} else {
// The diagnostic remains useful historical evidence, but a deleted
// legacy Worker has no Workspace binding from which a stable UUID
// can be reconstructed.
diagnostic.remove("worker_ref");
counts.cleared += 1;
}
}
}
let object = document.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
runtime_path,
"Runtime snapshot must be an object".to_string(),
)
})?;
object.insert(
"schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION),
);
object.remove("workers");
object.remove("next_worker_sequence");
let snapshot: RuntimeSnapshot = serde_json::from_value(document.clone()).map_err(|error| {
runtime_store_corrupt(
runtime_path,
format!("decode migrated Runtime snapshot: {error}"),
)
})?;
snapshot.validate(runtime_path)?;
Ok((document, counts))
}
fn migration_sibling(root: &Path, suffix: &str) -> Result<PathBuf, RuntimeError> {
let parent = root.parent().ok_or_else(|| {
runtime_store_corrupt(
root,
"Runtime store root has no parent directory".to_string(),
)
})?;
let name = root
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
runtime_store_corrupt(root, "Runtime store root name is not UTF-8".to_string())
})?;
Ok(parent.join(format!(".{name}.{suffix}")))
}
fn runtime_ephemeral_socket(root: &Path, path: &Path) -> Result<bool, RuntimeError> {
let relative = path.strip_prefix(root).map_err(|_| {
runtime_store_corrupt(
path,
format!("Runtime migration path escaped root {}", root.display()),
)
})?;
let components = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
let known_path = components.len() == 5
&& components[0] == WORKERS_DIR
&& (components[1].parse::<u64>().is_ok() || WorkerId::parse(&components[1]).is_some())
&& components[2] == "runs"
&& components[3].parse::<u64>().is_ok()
&& components[4] == "worker.sock";
if !known_path {
return Ok(false);
}
#[cfg(unix)]
{
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(runtime_store_corrupt(
path,
"Runtime migration found an active Worker socket; stop the legacy Runtime and Worker before migrating"
.to_string(),
)),
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
) => Ok(true),
Err(error) => Err(runtime_store_corrupt(
path,
format!("Runtime migration could not verify Worker socket liveness: {error}"),
)),
}
}
#[cfg(not(unix))]
Ok(false)
}
fn collect_runtime_tree_exclusions(
root: &Path,
source: &Path,
excluded: &mut Vec<String>,
) -> Result<(), RuntimeError> {
let entries = fs::read_dir(source)
.map_err(|error| runtime_io_error("read migration source", source, error))?;
for entry in entries {
let entry =
entry.map_err(|error| runtime_io_error("read migration source", source, error))?;
let source_path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
if file_type.is_dir() {
collect_runtime_tree_exclusions(root, &source_path, excluded)?;
} else if file_type.is_file() {
} else if runtime_ephemeral_socket(root, &source_path)? {
excluded.push(
source_path
.strip_prefix(root)
.expect("validated Runtime migration path")
.to_string_lossy()
.into_owned(),
);
} else {
return Err(runtime_store_corrupt(
&source_path,
"Runtime migration refuses unknown symlinks and special files".to_string(),
));
}
}
Ok(())
}
fn runtime_tree_exclusions(root: &Path) -> Result<Vec<String>, RuntimeError> {
let mut excluded = Vec::new();
collect_runtime_tree_exclusions(root, root, &mut excluded)?;
excluded.sort();
Ok(excluded)
}
fn copy_runtime_tree(root: &Path, source: &Path, target: &Path) -> Result<(), RuntimeError> {
fs::create_dir(target)
.map_err(|error| runtime_io_error("create migration staging", target, error))?;
let mut entries = fs::read_dir(source)
.map_err(|error| runtime_io_error("read migration source", source, error))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| runtime_io_error("read migration source", source, error))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|error| runtime_io_error("inspect migration source", &source_path, error))?;
if file_type.is_dir() {
copy_runtime_tree(root, &source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)
.map_err(|error| runtime_io_error("copy migration source", &source_path, error))?;
} else if runtime_ephemeral_socket(root, &source_path)? {
continue;
} else {
return Err(runtime_store_corrupt(
&source_path,
"Runtime migration refuses unknown symlinks and special files".to_string(),
));
}
}
Ok(())
}
fn migrate_runtime_store(
root: &Path,
runtime_id: &str,
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
let (plan, _) = plan_runtime_store_migration(root, runtime_id)?;
if !plan.migration_required {
return Ok(plan);
}
let staging = migration_sibling(root, "schema-v3-staging")?;
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
format!(
"unfinished Runtime migration artifact exists (staging={}, backup={}); recover or remove it before retrying",
staging.display(),
backup.display()
),
));
}
if let Err(error) = copy_runtime_tree(root, root, &staging) {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
let staged_plan = match migrate_runtime_store_in_place(&staging, runtime_id) {
Ok(plan) => plan,
Err(error) => {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
};
let staged_store = FsRuntimeStore {
root: staging.clone(),
};
if let Err(error) = staged_store.load_runtime_state() {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
fs::rename(root, &backup)
.map_err(|error| runtime_io_error("backup runtime store", root, error))?;
if let Err(error) = fs::rename(&staging, root) {
let rollback = fs::rename(&backup, root);
return match rollback {
Ok(()) => Err(runtime_io_error(
"activate migrated runtime store",
&staging,
error,
)),
Err(rollback_error) => Err(runtime_store_corrupt(
root,
format!(
"activate migrated Runtime store failed: {error}; rollback failed: {rollback_error}; backup remains at {}",
backup.display()
),
)),
};
}
fs::remove_dir_all(&backup)
.map_err(|error| runtime_io_error("remove runtime migration backup", &backup, error))?;
debug_assert_eq!(plan.mapping_digest, staged_plan.mapping_digest);
Ok(plan)
}
fn migrate_runtime_store_in_place(
root: &Path,
runtime_id: &str,
) -> Result<FsRuntimeStoreMigrationPlan, RuntimeError> {
let (plan, planned_workers) = plan_runtime_store_migration(root, runtime_id)?;
if !plan.migration_required {
return Ok(plan);
}
let runtime_path = root.join(RUNTIME_FILE);
let bytes =
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
runtime_store_corrupt(
&runtime_path,
format!("decode Runtime state {}: {error}", runtime_path.display()),
)
})?;
for planned_worker in &planned_workers {
let source_dir = &planned_worker.source_dir;
let source_snapshot_path = source_dir.join(WORKER_FILE);
let bytes = fs::read(&source_snapshot_path)
.map_err(|error| runtime_io_error("read", &source_snapshot_path, error))?;
let snapshot: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| {
runtime_store_corrupt(
&source_snapshot_path,
format!(
"decode Worker snapshot {}: {error}",
source_snapshot_path.display()
),
)
})?;
let snapshot = migrate_worker_document(
snapshot,
plan.current_schema_version,
planned_worker.legacy_mapping.as_ref(),
&source_snapshot_path,
)?;
let metadata_path = source_dir.join(WORKER_METADATA_FILE);
let metadata = if metadata_path.is_file() {
let metadata: serde_json::Value =
read_json(&metadata_path, "read Worker aggregate metadata")?;
Some(
migrate_worker_aggregate_document(
metadata,
planned_worker,
runtime_id,
&metadata_path,
)?
.0,
)
} else {
None
};
let worker_id_text = planned_worker.worker_id.to_string();
let migrated_dir = root.join("workers").join(&worker_id_text);
if source_dir != &migrated_dir {
fs::rename(source_dir, &migrated_dir)
.map_err(|error| runtime_io_error("rename", source_dir, error))?;
}
let migrated_snapshot_path = migrated_dir.join(WORKER_FILE);
atomic_write_json(
&migrated_snapshot_path,
&snapshot,
"migrate Worker identity",
)?;
if let Some(metadata) = metadata {
atomic_write_json(
&migrated_dir.join(WORKER_METADATA_FILE),
&metadata,
"migrate Worker aggregate identity",
)?;
}
}
let (document, _) = migrate_runtime_document(
document,
plan.current_schema_version,
&plan.mappings,
&runtime_path,
)?;
atomic_write_json(
&runtime_path,
&document,
"migrate Runtime Worker identities",
)?;
Ok(plan)
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
struct RuntimeSnapshot { struct RuntimeSnapshot {
schema_version: u32, schema_version: u32,
display_name: Option<String>, display_name: Option<String>,
backend: RuntimeBackendKind, backend: RuntimeBackendKind,
status: RuntimeStatus, status: RuntimeStatus,
next_worker_sequence: u64,
next_diagnostic_id: u64, next_diagnostic_id: u64,
#[serde(default)] #[serde(default)]
config_bundles: BTreeMap<String, ConfigBundle>, config_bundles: BTreeMap<String, ConfigBundle>,
@@ -297,7 +1187,6 @@ impl RuntimeSnapshot {
display_name: state.display_name.clone(), display_name: state.display_name.clone(),
backend: RuntimeBackendKind::FsStore, backend: RuntimeBackendKind::FsStore,
status: state.status, status: state.status,
next_worker_sequence: state.next_worker_sequence,
next_diagnostic_id: state.next_diagnostic_id, next_diagnostic_id: state.next_diagnostic_id,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
workspace_owners: state.workspace_owners.clone(), workspace_owners: state.workspace_owners.clone(),
@@ -333,7 +1222,6 @@ impl RuntimeSnapshot {
PersistedRuntimeState { PersistedRuntimeState {
display_name: self.display_name, display_name: self.display_name,
status: self.status, status: self.status,
next_worker_sequence: self.next_worker_sequence,
next_diagnostic_id: self.next_diagnostic_id, next_diagnostic_id: self.next_diagnostic_id,
workers, workers,
workspace_owners: self.workspace_owners, workspace_owners: self.workspace_owners,
+8 -6
View File
@@ -2162,8 +2162,8 @@ mod tests {
let profile = ProfileSelector::Builtin("builtin:coder".to_string()); let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone()); let bundle = test_bundle(profile.clone());
CreateWorkerRequest { CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile, profile,
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http { profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
@@ -2659,12 +2659,14 @@ mod tests {
async fn runtime_errors_use_typed_rest_error_shape() { async fn runtime_errors_use_typed_rest_error_shape() {
let token = "local-token"; let token = "local-token";
let app = runtime_http_router(Runtime::new_memory(), token.to_string()); let app = runtime_http_router(Runtime::new_memory(), token.to_string());
let response = authed_empty_request(app, Method::GET, "/v1/workers/999", token).await; let missing = crate::identity::WorkerId::from_legacy_u64(999);
let response =
authed_empty_request(app, Method::GET, &format!("/v1/workers/{missing}"), token).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await; let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "worker_not_found"); assert_eq!(error.error.code, "worker_not_found");
assert!(error.error.message.contains("999")); assert!(error.error.message.contains(&missing.to_string()));
} }
#[tokio::test] #[tokio::test]
@@ -2795,8 +2797,8 @@ mod ws_tests {
fn ws_create_request() -> CreateWorkerRequest { fn ws_create_request() -> CreateWorkerRequest {
let bundle = ws_test_bundle(ProfileSelector::Builtin("builtin:companion".to_string())); let bundle = ws_test_bundle(ProfileSelector::Builtin("builtin:companion".to_string()));
CreateWorkerRequest { CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::Builtin("builtin:companion".to_string()),
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http { profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
+145 -34
View File
@@ -1,50 +1,146 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::fmt; use sha2::{Digest, Sha256};
use std::{fmt, str::FromStr};
use uuid::{Uuid, Version};
pub use workdir::workspace::RuntimeWorkerRef; pub use workdir::workspace::RuntimeWorkerRef;
/// Runtime-local Worker identity. /// Stable Workspace-owned Worker identity.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] ///
#[serde(transparent)] /// Runtime placement is deliberately not part of this value. New identities are
pub struct WorkerId(u64); /// allocated by Workspace authority before a Runtime create request is sent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct WorkerId(Uuid);
impl WorkerId { impl WorkerId {
pub fn new(value: u64) -> Self { pub fn now_v7() -> Self {
Self(value) Self(Uuid::now_v7())
}
/// Converts a legacy Runtime-local numeric id into a syntactically valid
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
pub fn from_legacy_u64(value: u64) -> Self {
let mut bytes = [0_u8; 16];
bytes[8..].copy_from_slice(&value.to_be_bytes());
bytes[6] = 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-id.v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(runtime_id.as_bytes());
hasher.update([0]);
hasher.update(value.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Migrated ids sort before normally allocated UUIDv7 values while retaining
// deterministic collision-resistant payload bits.
bytes[..6].fill(0);
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
} }
pub fn parse(value: &str) -> Option<Self> { pub fn parse(value: &str) -> Option<Self> {
value.parse::<u64>().ok().map(Self) let value = Uuid::parse_str(value).ok()?;
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
} }
pub(crate) fn generated(sequence: u64) -> Self { pub const fn as_uuid(self) -> Uuid {
Self(sequence)
}
pub fn as_u64(&self) -> u64 {
self.0 self.0
} }
} }
impl fmt::Display for WorkerId { impl fmt::Display for WorkerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f) self.0.fmt(formatter)
} }
} }
/// Convert an opaque Workspace Worker reference only at the Runtime-local boundary. impl FromStr for WorkerId {
impl TryFrom<&RuntimeWorkerRef> for WorkerRef { type Err = WorkerIdParseError;
type Error = std::num::ParseIntError;
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> { fn from_str(value: &str) -> Result<Self, Self::Err> {
value Self::parse(value).ok_or(WorkerIdParseError)
.worker_id
.parse::<u64>()
.map(WorkerId::new)
.map(Self::new)
} }
} }
/// Runtime-local authority reference for Worker operations. impl Serialize for WorkerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WorkerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerIdParseError;
impl fmt::Display for WorkerIdParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Worker id must be a UUIDv7")
}
}
impl std::error::Error for WorkerIdParseError {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyWorkerIdentityMapping {
pub workspace_id: String,
pub runtime_id: String,
pub legacy_worker_id: u64,
pub worker_id: WorkerId,
}
pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMapping]) -> String {
let mut mappings = mappings.to_vec();
mappings.sort_by(|left, right| {
(
left.workspace_id.as_str(),
left.runtime_id.as_str(),
left.legacy_worker_id,
left.worker_id,
)
.cmp(&(
right.workspace_id.as_str(),
right.runtime_id.as_str(),
right.legacy_worker_id,
right.worker_id,
))
});
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-migration-plan.v1\0");
for mapping in mappings {
hasher.update(mapping.workspace_id.as_bytes());
hasher.update([0]);
hasher.update(mapping.runtime_id.as_bytes());
hasher.update([0]);
hasher.update(mapping.legacy_worker_id.to_be_bytes());
hasher.update(mapping.worker_id.to_string().as_bytes());
hasher.update([b'\n']);
}
let digest = hasher.finalize();
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Runtime-local authority reference for Worker operations. The contained id is
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WorkerRef { pub struct WorkerRef {
pub worker_id: WorkerId, pub worker_id: WorkerId,
@@ -56,28 +152,43 @@ impl WorkerRef {
} }
} }
impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
type Error = WorkerIdParseError;
fn try_from(value: &RuntimeWorkerRef) -> Result<Self, Self::Error> {
value.worker_id.parse().map(Self::new)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn runtime_worker_ref_preserves_structured_identity_and_json_fields() { fn worker_id_accepts_only_uuid_v7() {
let worker = RuntimeWorkerRef::new("arcadia", "30"); let worker_id = WorkerId::now_v7();
assert_eq!(worker.runtime_id, "arcadia"); assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
assert_eq!(worker.worker_id, "30"); assert!(WorkerId::parse("30").is_none());
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
}
#[test]
fn runtime_worker_ref_preserves_stable_worker_identity() {
let worker_id = WorkerId::now_v7();
let worker = RuntimeWorkerRef::new("arcadia", worker_id.to_string());
assert_eq!( assert_eq!(
WorkerRef::try_from(&worker).unwrap(), WorkerRef::try_from(&worker).unwrap(),
WorkerRef::new(WorkerId::new(30)) WorkerRef::new(worker_id)
); );
assert_eq!( assert_eq!(
serde_json::to_value(&worker).unwrap(), serde_json::to_value(&worker).unwrap(),
serde_json::json!({"runtime_id": "arcadia", "worker_id": "30"}) serde_json::json!({"runtime_id": "arcadia", "worker_id": worker_id.to_string()})
); );
} }
#[test] #[test]
fn runtime_worker_ref_does_not_treat_composite_text_as_local_worker_id() { fn runtime_worker_ref_rejects_legacy_numeric_identity() {
let worker = RuntimeWorkerRef::new("arcadia", "embedded-worker-runtime-5"); let worker = RuntimeWorkerRef::new("arcadia", "30");
assert!(WorkerRef::try_from(&worker).is_err()); assert!(WorkerRef::try_from(&worker).is_err());
} }
} }
+164 -2
View File
@@ -18,7 +18,7 @@ use worker_runtime::auth::{
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
}; };
use worker_runtime::error::RuntimeError; use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
use worker_runtime::http_server::{ use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
}; };
@@ -44,6 +44,9 @@ fn main() -> ExitCode {
fn run() -> Result<(), ProcessError> { fn run() -> Result<(), ProcessError> {
let args = env::args().skip(1).collect::<Vec<_>>(); let args = env::args().skip(1).collect::<Vec<_>>();
if matches!(args.first().map(String::as_str), Some("migrate")) {
return run_migration_command(args);
}
if matches!( if matches!(
args.first().map(String::as_str), args.first().map(String::as_str),
Some("identity" | "trust-server") Some("identity" | "trust-server")
@@ -78,6 +81,76 @@ fn run() -> Result<(), ProcessError> {
Ok(()) Ok(())
} }
fn run_migration_command(mut args: Vec<String>) -> Result<(), ProcessError> {
args.remove(0);
let dry_run_index = args
.iter()
.position(|argument| argument == "--dry-run")
.ok_or_else(|| ProcessError::usage("migrate currently requires --dry-run".to_string()))?;
args.remove(dry_run_index);
let explicit_runtime_id =
if let Some(index) = args.iter().position(|argument| argument == "--runtime-id") {
if index + 1 >= args.len() {
return Err(ProcessError::usage(
"--runtime-id requires a value".to_string(),
));
}
let runtime_id = args.remove(index + 1);
args.remove(index);
Some(runtime_id)
} else {
None
};
let config = parse_args(args)?.ok_or_else(|| {
ProcessError::usage("migrate requires a Runtime store configuration".to_string())
})?;
let root = match &config.http.store {
RuntimeHttpStoreSelection::Fs { root } => root.clone(),
RuntimeHttpStoreSelection::Memory => {
return Err(ProcessError::usage(
"migration dry-run requires the fs Runtime store".to_string(),
));
}
_ => {
return Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(),
));
}
};
let persisted_runtime_id = read_runtime_auth_file(&runtime_auth_path(&config))?
.identity
.map(|identity| identity.identity_id);
let runtime_id = match (persisted_runtime_id, explicit_runtime_id) {
(Some(persisted), Some(explicit)) if persisted != explicit => {
return Err(ProcessError::usage(format!(
"--runtime-id {explicit} does not match persisted Runtime identity {persisted}"
)));
}
(Some(persisted), _) => persisted,
(None, Some(explicit)) if !explicit.is_empty() => explicit,
(None, Some(_)) => {
return Err(ProcessError::usage(
"--runtime-id must not be empty".to_string(),
));
}
(None, None) => {
return Err(ProcessError::usage(
"migration dry-run requires a persisted Runtime identity or explicit --runtime-id"
.to_string(),
));
}
};
let mut options = FsRuntimeStoreOptions::new(root).with_runtime_id(runtime_id);
options.display_name = config.http.display_name.clone();
let plan = FsRuntimeStore::migration_plan(&options).map_err(ProcessError::Runtime)?;
println!(
"{}",
serde_json::to_string_pretty(&plan)
.map_err(|error| ProcessError::Auth(format!("encode migration plan: {error}")))?
);
Ok(())
}
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> { fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
let fs_paths = config.resolved_fs_paths(); let fs_paths = config.resolved_fs_paths();
let runtime_store_dir = match &config.http.store { let runtime_store_dir = match &config.http.store {
@@ -112,7 +185,14 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)
} }
RuntimeHttpStoreSelection::Fs { root } => { RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()); let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
config
.http
.auth
.as_ref()
.map(|auth| auth.runtime_id.as_str())
.unwrap_or("local"),
);
options.display_name = config.http.display_name.clone(); options.display_name = config.http.display_name.clone();
Runtime::with_fs_store_and_execution_backend(options, backend) Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)
@@ -784,6 +864,7 @@ fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessErr
fn usage() -> &'static str { fn usage() -> &'static str {
r#"Usage: yoi-runtime [OPTIONS] r#"Usage: yoi-runtime [OPTIONS]
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
Starts a worker-backed Runtime REST command API for a trusted backend/proxy. Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
Browsers must not connect to this Runtime process directly. Browsers must not connect to this Runtime process directly.
@@ -904,6 +985,87 @@ mod tests {
assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs")); assert_eq!(paths.workdir_target, PathBuf::from("/tmp/yoi-workdirs"));
} }
#[test]
fn migration_dry_run_accepts_real_v1_document_without_workers_field() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write(
root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"display_name": "local",
"backend": "fs_store",
"status": "running",
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"assignments": [],
"execution": [],
"diagnostics": []
}))
.unwrap(),
)
.unwrap();
let before = std::fs::read(root.join("runtime.json")).unwrap();
run_migration_command(vec![
"migrate".to_string(),
"--dry-run".to_string(),
"--runtime-id".to_string(),
"local".to_string(),
"--store".to_string(),
"fs".to_string(),
"--fs-root".to_string(),
temp.path().display().to_string(),
"--fs-runtime-dir".to_string(),
root.display().to_string(),
])
.unwrap();
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
}
#[test]
fn migration_dry_run_rejects_v1_document_that_cannot_decode_as_v3() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write(
root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"display_name": "local",
"backend": "fs_store",
"status": 3,
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}))
.unwrap(),
)
.unwrap();
let before = std::fs::read(root.join("runtime.json")).unwrap();
let error = run_migration_command(vec![
"migrate".to_string(),
"--dry-run".to_string(),
"--runtime-id".to_string(),
"local".to_string(),
"--store".to_string(),
"fs".to_string(),
"--fs-root".to_string(),
temp.path().display().to_string(),
"--fs-runtime-dir".to_string(),
root.display().to_string(),
])
.unwrap_err();
assert!(
error
.to_string()
.contains("decode migrated Runtime snapshot")
);
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
}
#[test] #[test]
fn no_store_disables_runtime_catalog_persistence() { fn no_store_disables_runtime_catalog_persistence() {
let config = parse_args(["--no-store"]).unwrap().unwrap(); let config = parse_args(["--no-store"]).unwrap().unwrap();
+48 -23
View File
@@ -275,14 +275,13 @@ impl FsWorkerRetentionProvider {
)); ));
continue; continue;
} }
let Ok(worker_number) = raw_id.parse::<u64>() else { let Ok(worker_id) = raw_id.parse::<WorkerId>() else {
diagnostics.push(runtime_aggregate_diagnostic( diagnostics.push(runtime_aggregate_diagnostic(
&bounded_id, &bounded_id,
"aggregate_worker_id_invalid", "aggregate_worker_id_invalid",
)); ));
continue; continue;
}; };
let worker_id = WorkerId::new(worker_number);
let worker_dir = self.worker_dir(worker_id); let worker_dir = self.worker_dir(worker_id);
let snapshot: WorkerGenerationSnapshot = match read_json( let snapshot: WorkerGenerationSnapshot = match read_json(
&worker_dir.join("worker.json"), &worker_dir.join("worker.json"),
@@ -1315,7 +1314,7 @@ mod tests {
#[test] #[test]
fn archive_is_verified_before_source_removal_and_retry_converges() { fn archive_is_verified_before_source_removal_and_retry_converges() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(7); let worker_id = WorkerId::from_legacy_u64(7);
source(temp.path(), worker_id, 4); source(temp.path(), worker_id, 4);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 4, SessionDisposition::Archive); let request = request(worker_id, 4, SessionDisposition::Archive);
@@ -1325,7 +1324,7 @@ mod tests {
let archive = first.archive.as_ref().unwrap(); let archive = first.archive.as_ref().unwrap();
assert_eq!(archive.source_session_id, "session-a"); assert_eq!(archive.source_session_id, "session-a");
assert_eq!(archive.segment_ids, vec!["segment-a"]); assert_eq!(archive.segment_ids, vec!["segment-a"]);
assert!(!temp.path().join("workers/7").exists()); assert!(!temp.path().join(format!("workers/{worker_id}")).exists());
assert!( assert!(
temp.path() temp.path()
.join("archives/workers/archive-a/session/segments/segment-a.jsonl") .join("archives/workers/archive-a/session/segments/segment-a.jsonl")
@@ -1346,7 +1345,7 @@ mod tests {
#[test] #[test]
fn archive_failure_keeps_live_source_for_retry() { fn archive_failure_keeps_live_source_for_retry() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(8); let worker_id = WorkerId::from_legacy_u64(8);
source(temp.path(), worker_id, 2); source(temp.path(), worker_id, 2);
let collision = temp.path().join("archives/workers/archive-a"); let collision = temp.path().join("archives/workers/archive-a");
fs::create_dir_all(&collision).unwrap(); fs::create_dir_all(&collision).unwrap();
@@ -1358,7 +1357,11 @@ mod tests {
.execute(&request(worker_id, 2, SessionDisposition::Archive)) .execute(&request(worker_id, 2, SessionDisposition::Archive))
.is_err() .is_err()
); );
assert!(temp.path().join("workers/8/session").is_dir()); assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
assert!( assert!(
!temp !temp
.path() .path()
@@ -1370,7 +1373,7 @@ mod tests {
#[test] #[test]
fn target_inventory_and_execute_reject_cross_workspace_aggregate() { fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(16); let worker_id = WorkerId::from_legacy_u64(16);
source(temp.path(), worker_id, 3); source(temp.path(), worker_id, 3);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
assert!(matches!( assert!(matches!(
@@ -1383,7 +1386,11 @@ mod tests {
provider.execute(&request), provider.execute(&request),
Err(RuntimeError::WorkerNotFound { .. }) Err(RuntimeError::WorkerNotFound { .. })
)); ));
assert!(temp.path().join("workers/16/session").is_dir()); assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
assert!( assert!(
!temp !temp
.path() .path()
@@ -1401,18 +1408,22 @@ mod tests {
fn purge_removes_aggregate_and_rejects_stale_generation() { fn purge_removes_aggregate_and_rejects_stale_generation() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let worker_id = WorkerId::new(9); let worker_id = WorkerId::from_legacy_u64(9);
source(temp.path(), worker_id, 5); source(temp.path(), worker_id, 5);
let stale = request(worker_id, 4, SessionDisposition::Purge); let stale = request(worker_id, 4, SessionDisposition::Purge);
assert!(provider.execute(&stale).is_err()); assert!(provider.execute(&stale).is_err());
assert!(temp.path().join("workers/9/session").is_dir()); assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
let mut current = request(worker_id, 5, SessionDisposition::Purge); let mut current = request(worker_id, 5, SessionDisposition::Purge);
current.operation_id = "operation-current".to_string(); current.operation_id = "operation-current".to_string();
current.input_fingerprint = "fingerprint-current".to_string(); current.input_fingerprint = "fingerprint-current".to_string();
let result = provider.execute(&current).unwrap(); let result = provider.execute(&current).unwrap();
assert!(result.archive.is_none()); assert!(result.archive.is_none());
assert!(!temp.path().join("workers/9").exists()); assert!(!temp.path().join(format!("workers/{worker_id}")).exists());
assert!( assert!(
temp.path() temp.path()
.join("retention/operations/operation-current.json") .join("retention/operations/operation-current.json")
@@ -1423,7 +1434,7 @@ mod tests {
#[test] #[test]
fn pending_receipt_recovers_delete_to_receipt_crash_window() { fn pending_receipt_recovers_delete_to_receipt_crash_window() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(11); let worker_id = WorkerId::from_legacy_u64(11);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id, 1);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker_id, 1, SessionDisposition::Archive); let request = request(worker_id, 1, SessionDisposition::Archive);
@@ -1442,10 +1453,15 @@ mod tests {
#[test] #[test]
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() { fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
source(temp.path(), WorkerId::new(13), 2); source(temp.path(), WorkerId::from_legacy_u64(13), 2);
source(temp.path(), WorkerId::new(14), 1); let other_worker = WorkerId::from_legacy_u64(14);
source(temp.path(), other_worker, 1);
write_json( write_json(
&temp.path().join("workers/14/worker.json"), &temp
.path()
.join("workers")
.join(other_worker.to_string())
.join("worker.json"),
&serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}), &serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}),
); );
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap(); fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
@@ -1454,15 +1470,20 @@ mod tests {
b"not-json", b"not-json",
) )
.unwrap(); .unwrap();
fs::create_dir_all(temp.path().join("workers/15")).unwrap(); let corrupt_worker = WorkerId::from_legacy_u64(15);
fs::write(temp.path().join("workers/15/worker.json"), b"not-json").unwrap(); let corrupt_worker_dir = temp.path().join("workers").join(corrupt_worker.to_string());
fs::create_dir_all(&corrupt_worker_dir).unwrap();
fs::write(corrupt_worker_dir.join("worker.json"), b"not-json").unwrap();
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let snapshot = provider.snapshot("workspace-a", "runtime-a").unwrap(); let snapshot = provider.snapshot("workspace-a", "runtime-a").unwrap();
assert_eq!(snapshot.workers().len(), 1); assert_eq!(snapshot.workers().len(), 1);
assert_eq!(snapshot.workers()[0].worker_id, WorkerId::new(13)); assert_eq!(
snapshot.workers()[0].worker_id,
WorkerId::from_legacy_u64(13)
);
assert!(snapshot.diagnostics().iter().any(|diagnostic| { assert!(snapshot.diagnostics().iter().any(|diagnostic| {
diagnostic.worker_id() == "14" diagnostic.worker_id() == other_worker.to_string()
&& diagnostic.category() == "aggregate_workspace_mismatch" && diagnostic.category() == "aggregate_workspace_mismatch"
})); }));
assert!(snapshot.diagnostics().iter().any(|diagnostic| { assert!(snapshot.diagnostics().iter().any(|diagnostic| {
@@ -1470,7 +1491,7 @@ mod tests {
&& diagnostic.category() == "aggregate_worker_id_invalid" && diagnostic.category() == "aggregate_worker_id_invalid"
})); }));
assert!(snapshot.diagnostics().iter().any(|diagnostic| { assert!(snapshot.diagnostics().iter().any(|diagnostic| {
diagnostic.worker_id() == "15" diagnostic.worker_id() == corrupt_worker.to_string()
&& diagnostic.category() == "aggregate_worker_record_corrupt" && diagnostic.category() == "aggregate_worker_record_corrupt"
})); }));
} }
@@ -1478,7 +1499,7 @@ mod tests {
#[test] #[test]
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() { fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(12); let worker_id = WorkerId::from_legacy_u64(12);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id, 1);
let provider = FsWorkerRetentionProvider::new(temp.path()); let provider = FsWorkerRetentionProvider::new(temp.path());
let mut request = request(worker_id, 1, SessionDisposition::Archive); let mut request = request(worker_id, 1, SessionDisposition::Archive);
@@ -1499,14 +1520,18 @@ mod tests {
.unwrap(); .unwrap();
assert!(provider.execute(&request).is_err()); assert!(provider.execute(&request).is_err());
assert!(temp.path().join("workers/12/session").is_dir()); assert!(
temp.path()
.join(format!("workers/{worker_id}/session"))
.is_dir()
);
assert!(provider.completed_for(&request).unwrap().is_none()); assert!(provider.completed_for(&request).unwrap().is_none());
} }
#[test] #[test]
fn concurrent_retry_produces_one_archive() { fn concurrent_retry_produces_one_archive() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let worker_id = WorkerId::new(10); let worker_id = WorkerId::from_legacy_u64(10);
source(temp.path(), worker_id, 1); source(temp.path(), worker_id, 1);
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path())); let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive)); let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive));
+278 -32
View File
@@ -196,7 +196,7 @@ impl Runtime {
options: FsRuntimeStoreOptions, options: FsRuntimeStoreOptions,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
) -> Result<Self, RuntimeError> { ) -> Result<Self, RuntimeError> {
let opened = FsRuntimeStore::open_or_create(options.root)?; let opened = FsRuntimeStore::open_or_create(options.root, &options.runtime_id)?;
let mut state = if let Some(persisted) = opened.state { let mut state = if let Some(persisted) = opened.state {
RuntimeState::from_persisted(persisted, opened.store)? RuntimeState::from_persisted(persisted, opened.store)?
} else { } else {
@@ -492,7 +492,7 @@ impl Runtime {
let state = self.lock()?; let state = self.lock()?;
status.summary.primary_worker_id = state status.summary.primary_worker_id = state
.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str()) .primary_worker_id_for_workdir(status.summary.working_directory_id.as_str())
.map(|worker_id| worker_id.as_u64()); .map(|worker_id| worker_id.to_string());
Ok(status) Ok(status)
} }
@@ -518,11 +518,6 @@ impl Runtime {
request: CreateWorkerRequest, request: CreateWorkerRequest,
scope: Option<&RuntimeWorkspaceScope>, scope: Option<&RuntimeWorkspaceScope>,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
if request.idempotency_key.is_some() != request.idempotency_fingerprint.is_some() {
return Err(RuntimeError::InvalidRequest(
"idempotency_key and idempotency_fingerprint must be provided together".to_string(),
));
}
let (backend, worker_ref, spawn_request) = { let (backend, worker_ref, spawn_request) = {
let mut state = self.lock()?; let mut state = self.lock()?;
state.ensure_running()?; state.ensure_running()?;
@@ -534,19 +529,21 @@ impl Runtime {
if let Some(scope) = scope { if let Some(scope) = scope {
state.ensure_workspace_owner(scope, true)?; state.ensure_workspace_owner(scope, true)?;
}; };
if let Some(idempotency_key) = request.idempotency_key.as_deref() { let workspace_id = scope.map(|scope| scope.workspace_id.as_str());
let workspace_id = scope.map(|scope| scope.workspace_id.as_str()); if let Some(existing) = state.workers.get(&request.worker_id) {
if let Some(existing) = state.workers.values().find(|record| { if existing.workspace_id.as_deref() != workspace_id {
record.workspace_id.as_deref() == workspace_id return Err(RuntimeError::InvalidRequest(format!(
&& record.request.idempotency_key.as_deref() == Some(idempotency_key) "worker {} already belongs to another Workspace scope",
}) { request.worker_id
if existing.request.idempotency_fingerprint != request.idempotency_fingerprint { )));
return Err(RuntimeError::InvalidRequest(format!(
"worker creation idempotency key {idempotency_key} was already used with different input"
)));
}
return Ok(existing.detail());
} }
if existing.request.create_fingerprint != request.create_fingerprint {
return Err(RuntimeError::InvalidRequest(format!(
"worker {} was already created with a different fingerprint",
request.worker_id
)));
}
return Ok(existing.detail());
} }
state.validate_worker_config_boundary(&request)?; state.validate_worker_config_boundary(&request)?;
if let Some(working_directory_id) = requested_primary_workdir_id(&request) { if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
@@ -565,9 +562,8 @@ impl Runtime {
})?; })?;
let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?; let config_bundle = state.resolve_config_bundle_ref(request.config_bundle.as_ref())?;
let worker_id = WorkerId::generated(state.next_worker_sequence); let worker_id = request.worker_id;
state.next_worker_sequence += 1; let worker_ref = WorkerRef::new(worker_id);
let worker_ref = WorkerRef::new(worker_id.clone());
let record = WorkerRecord { let record = WorkerRecord {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
@@ -1850,7 +1846,6 @@ struct RuntimeState {
persistence: RuntimePersistence, persistence: RuntimePersistence,
status: RuntimeStatus, status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
next_worker_sequence: u64,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -1878,7 +1873,6 @@ impl RuntimeState {
persistence: RuntimePersistence::Memory, persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
next_worker_sequence: 1,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1907,7 +1901,6 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
next_worker_sequence: 1,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1958,7 +1951,6 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: persisted.status, status: persisted.status,
execution_backend: None, execution_backend: None,
next_worker_sequence: persisted.next_worker_sequence,
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
@@ -1982,7 +1974,6 @@ impl RuntimeState {
PersistedRuntimeState { PersistedRuntimeState {
display_name: self.display_name.clone(), display_name: self.display_name.clone(),
status: self.status, status: self.status,
next_worker_sequence: self.next_worker_sequence,
next_diagnostic_id: self.next_diagnostic_id, next_diagnostic_id: self.next_diagnostic_id,
workers: self workers: self
.workers .workers
@@ -2589,6 +2580,11 @@ fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
} }
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
if request.create_fingerprint.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"create_fingerprint must not be empty".to_string(),
));
}
match &request.profile_source { match &request.profile_source {
crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => { crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => {
archive.verify().map_err(|err| { archive.verify().map_err(|err| {
@@ -2791,8 +2787,8 @@ mod tests {
let profile = ProfileSelector::Builtin("builtin:coder".to_string()); let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle_for_profile(profile.clone()); let bundle = test_bundle_for_profile(profile.clone());
CreateWorkerRequest { CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile, profile,
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http { profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
@@ -3570,8 +3566,7 @@ mod tests {
fn create_worker_idempotency_reuses_worker_and_rejects_different_input() { fn create_worker_idempotency_reuses_worker_and_rejects_different_input() {
let runtime = runtime_with_backend(); let runtime = runtime_with_backend();
let mut request = task_request("idempotent"); let mut request = task_request("idempotent");
request.idempotency_key = Some("operation-1".to_string()); request.create_fingerprint = "sha256:input-1".to_string();
request.idempotency_fingerprint = Some("sha256:input-1".to_string());
request.working_directory = Some(WorkingDirectoryClaim { request.working_directory = Some(WorkingDirectoryClaim {
working_directory_id: "workdir-idempotent".to_string(), working_directory_id: "workdir-idempotent".to_string(),
relative_cwd: None, relative_cwd: None,
@@ -3587,7 +3582,7 @@ mod tests {
workdir_count_after_first workdir_count_after_first
); );
request.idempotency_fingerprint = Some("sha256:different".to_string()); request.create_fingerprint = "sha256:different".to_string();
let error = runtime.create_worker(request).unwrap_err(); let error = runtime.create_worker(request).unwrap_err();
assert!(matches!(error, RuntimeError::InvalidRequest(_))); assert!(matches!(error, RuntimeError::InvalidRequest(_)));
assert_eq!(runtime.list_workers().unwrap().len(), 1); assert_eq!(runtime.list_workers().unwrap().len(), 1);
@@ -4141,6 +4136,244 @@ mod tests {
} }
} }
#[cfg(feature = "fs-store")]
#[test]
fn fs_store_migrates_legacy_numeric_worker_identity_to_workspace_uuid() {
let root = fs_store_root("worker-id-v1");
let runtime_id = "arcadia";
let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
runtime_id: runtime_id.to_string(),
display_name: None,
},
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let worker = runtime
.create_worker_scoped(
&RuntimeWorkspaceScope::new("workspace-a", "server"),
task_request("legacy"),
)
.unwrap();
drop(runtime);
let current_dir = root.join("workers").join(worker.worker_id.to_string());
let legacy_dir = root.join("workers").join("7");
std::fs::rename(&current_dir, &legacy_dir).unwrap();
let worker_path = legacy_dir.join("worker.json");
let mut worker_json: serde_json::Value =
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
worker_json["schema_version"] = serde_json::json!(1);
worker_json["worker_id"] = serde_json::json!(7);
worker_json["worker_ref"]["worker_id"] = serde_json::json!(7);
let request = worker_json["request"].as_object_mut().unwrap();
request.remove("worker_id");
request.remove("create_fingerprint");
request.insert("idempotency_key".to_string(), serde_json::Value::Null);
request.insert(
"idempotency_fingerprint".to_string(),
serde_json::Value::Null,
);
std::fs::write(
&worker_path,
serde_json::to_vec_pretty(&worker_json).unwrap(),
)
.unwrap();
let legacy_worker_name = "worker-runtime-7";
let legacy_manifest = manifest::WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "{legacy_worker_name}"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
[[scope.allow]]
target = "/tmp"
permission = "write"
"#,
))
.unwrap();
std::fs::write(
legacy_dir.join("metadata.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"worker_name": legacy_worker_name,
"workspace_id": "workspace-a",
"resolved_manifest_snapshot": legacy_manifest
}))
.unwrap(),
)
.unwrap();
let runtime_path = root.join("runtime.json");
let mut runtime_json: serde_json::Value =
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
runtime_json["schema_version"] = serde_json::json!(1);
runtime_json["workers"] = serde_json::json!({"legacy": "ignored"});
runtime_json["next_worker_sequence"] = serde_json::json!(8);
runtime_json["next_diagnostic_id"] = serde_json::json!(3);
runtime_json["diagnostics"] = serde_json::json!([
{
"id": 1,
"severity": "warning",
"code": "mapped_legacy_worker",
"message": "mapped diagnostic",
"worker_ref": {"worker_id": 7}
},
{
"id": 2,
"severity": "warning",
"code": "deleted_legacy_worker",
"message": "unmapped diagnostic",
"worker_ref": {"worker_id": 6}
}
]);
std::fs::write(
&runtime_path,
serde_json::to_vec_pretty(&runtime_json).unwrap(),
)
.unwrap();
#[cfg(unix)]
{
let run_dir = legacy_dir.join("runs").join("6");
std::fs::create_dir_all(&run_dir).unwrap();
let socket =
std::os::unix::net::UnixListener::bind(run_dir.join("worker.sock")).unwrap();
drop(socket);
}
let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
runtime_id: runtime_id.to_string(),
display_name: None,
};
let runtime_before_dry_run = std::fs::read(&runtime_path).unwrap();
let plan = crate::fs_store::FsRuntimeStore::migration_plan(&runtime_options).unwrap();
assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1);
assert_eq!(plan.migrated_worker_aggregate_count, 1);
assert_eq!(plan.migrated_diagnostic_worker_ref_count, 1);
assert_eq!(plan.cleared_diagnostic_worker_ref_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
#[cfg(unix)]
assert_eq!(
plan.excluded_ephemeral_paths,
vec!["workers/7/runs/6/worker.sock"]
);
assert_eq!(
std::fs::read(&runtime_path).unwrap(),
runtime_before_dry_run
);
assert!(legacy_dir.exists());
let restored = Runtime::with_fs_store(runtime_options.clone()).unwrap();
let expected = WorkerId::from_legacy_binding("workspace-a", runtime_id, 7);
let detail = restored.worker_detail(&WorkerRef::new(expected)).unwrap();
assert_eq!(detail.worker_id, expected);
assert_eq!(detail.worker_ref.worker_id, expected);
let expected_worker_dir = root.join("workers").join(expected.to_string());
assert!(expected_worker_dir.exists());
#[cfg(unix)]
assert!(!expected_worker_dir.join("runs/6/worker.sock").exists());
assert!(!legacy_dir.exists());
let migrated_runtime: serde_json::Value =
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
assert_eq!(migrated_runtime["schema_version"], serde_json::json!(3));
assert!(migrated_runtime.get("workers").is_none());
assert!(migrated_runtime.get("next_worker_sequence").is_none());
assert_eq!(
migrated_runtime["diagnostics"][0]["worker_ref"]["worker_id"],
serde_json::json!(expected.to_string())
);
assert!(
migrated_runtime["diagnostics"][1]
.get("worker_ref")
.is_none()
);
let diagnostics = restored.diagnostics().unwrap();
assert_eq!(
diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "mapped_legacy_worker")
.and_then(|diagnostic| diagnostic.worker_ref.as_ref()),
Some(&WorkerRef::new(expected))
);
assert!(
diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "deleted_legacy_worker")
.is_some_and(|diagnostic| diagnostic.worker_ref.is_none())
);
let metadata_path = expected_worker_dir.join("metadata.json");
let mut migrated_metadata: serde_json::Value =
serde_json::from_slice(&std::fs::read(&metadata_path).unwrap()).unwrap();
let expected_worker_name = format!("worker-runtime-{expected}");
assert_eq!(
migrated_metadata["worker_name"],
serde_json::json!(expected_worker_name)
);
assert_eq!(
migrated_metadata["resolved_manifest_snapshot"]["worker"]["name"],
serde_json::json!(expected_worker_name)
);
drop(restored);
let mut schema_v2_runtime: serde_json::Value =
serde_json::from_slice(&std::fs::read(&runtime_path).unwrap()).unwrap();
schema_v2_runtime["schema_version"] = serde_json::json!(2);
std::fs::write(
&runtime_path,
serde_json::to_vec_pretty(&schema_v2_runtime).unwrap(),
)
.unwrap();
let migrated_worker_path = expected_worker_dir.join("worker.json");
let mut schema_v2_worker: serde_json::Value =
serde_json::from_slice(&std::fs::read(&migrated_worker_path).unwrap()).unwrap();
schema_v2_worker["schema_version"] = serde_json::json!(2);
std::fs::write(
&migrated_worker_path,
serde_json::to_vec_pretty(&schema_v2_worker).unwrap(),
)
.unwrap();
migrated_metadata["worker_name"] = serde_json::json!(legacy_worker_name);
migrated_metadata["resolved_manifest_snapshot"]["worker"]["name"] =
serde_json::json!(legacy_worker_name);
std::fs::write(
&metadata_path,
serde_json::to_vec_pretty(&migrated_metadata).unwrap(),
)
.unwrap();
let recovery_plan =
crate::fs_store::FsRuntimeStore::migration_plan(&runtime_options).unwrap();
assert_eq!(recovery_plan.current_schema_version, 2);
assert_eq!(recovery_plan.target_schema_version, 3);
assert!(recovery_plan.migration_required);
assert_eq!(recovery_plan.worker_count, 1);
assert_eq!(recovery_plan.migrated_worker_aggregate_count, 1);
assert!(recovery_plan.mappings.is_empty());
let recovered = Runtime::with_fs_store(runtime_options).unwrap();
let recovered_metadata: serde_json::Value =
serde_json::from_slice(&std::fs::read(metadata_path).unwrap()).unwrap();
assert_eq!(
recovered_metadata["worker_name"],
serde_json::json!(expected_worker_name)
);
assert_eq!(
recovered_metadata["resolved_manifest_snapshot"]["worker"]["name"],
serde_json::json!(expected_worker_name)
);
drop(recovered);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
#[test] #[test]
fn fs_store_restores_workers_without_legacy_event_or_protocol_observation_logs() { fn fs_store_restores_workers_without_legacy_event_or_protocol_observation_logs() {
@@ -4148,6 +4381,7 @@ mod tests {
let runtime = Runtime::with_fs_store_and_execution_backend( let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: Some("filesystem runtime".to_string()), display_name: Some("filesystem runtime".to_string()),
}, },
Arc::new(TestExecutionBackend::default()), Arc::new(TestExecutionBackend::default()),
@@ -4189,6 +4423,7 @@ mod tests {
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.unwrap(); .unwrap();
@@ -4239,6 +4474,7 @@ mod tests {
let runtime = Runtime::with_fs_store_and_execution_backend( let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
Arc::new(TestExecutionBackend::default()), Arc::new(TestExecutionBackend::default()),
@@ -4264,6 +4500,7 @@ mod tests {
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.unwrap(); .unwrap();
@@ -4313,6 +4550,7 @@ mod tests {
let runtime = Runtime::with_fs_store_and_execution_backend( let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
Arc::new(TestExecutionBackend::default()), Arc::new(TestExecutionBackend::default()),
@@ -4326,6 +4564,7 @@ mod tests {
let backendless = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let backendless = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.unwrap(); .unwrap();
@@ -4337,6 +4576,7 @@ mod tests {
let restored = Runtime::with_fs_store_and_execution_backend( let restored = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
restoring_backend.clone(), restoring_backend.clone(),
@@ -4360,6 +4600,7 @@ mod tests {
let runtime = Runtime::with_fs_store_and_execution_backend( let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
Arc::new(TestExecutionBackend::default()), Arc::new(TestExecutionBackend::default()),
@@ -4379,6 +4620,7 @@ mod tests {
let restored = Runtime::with_fs_store_and_execution_backend( let restored = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(), root: root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
restoring_backend.clone(), restoring_backend.clone(),
@@ -4415,6 +4657,7 @@ mod tests {
let corrupt_root = fs_store_root("corrupt"); let corrupt_root = fs_store_root("corrupt");
let corrupt_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let corrupt_runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: corrupt_root.clone(), root: corrupt_root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.unwrap(); .unwrap();
@@ -4427,6 +4670,7 @@ mod tests {
drop(corrupt_runtime); drop(corrupt_runtime);
let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let err = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: corrupt_root.clone(), root: corrupt_root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.unwrap_err(); .unwrap_err();
@@ -4437,6 +4681,7 @@ mod tests {
let missing_runtime = Runtime::with_fs_store_and_execution_backend( let missing_runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions { crate::fs_store::FsRuntimeStoreOptions {
root: missing_root.clone(), root: missing_root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}, },
Arc::new(TestExecutionBackend::default()), Arc::new(TestExecutionBackend::default()),
@@ -4456,6 +4701,7 @@ mod tests {
drop(missing_runtime); drop(missing_runtime);
let loaded = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions { let loaded = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: missing_root.clone(), root: missing_root.clone(),
runtime_id: "test-runtime".to_string(),
display_name: None, display_name: None,
}) })
.expect("invalid worker snapshot should not make runtime store unreadable"); .expect("invalid worker snapshot should not make runtime store unreadable");
+33 -16
View File
@@ -1998,6 +1998,7 @@ mod tests {
WorkingDirectoryRequest, WorkingDirectoryRequest,
}; };
use crate::execution::WorkerExecutionContext; use crate::execution::WorkerExecutionContext;
use crate::identity::WorkerId;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::management::RuntimeOptions; use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
@@ -2119,7 +2120,7 @@ mod tests {
#[test] #[test]
fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() { fn restart_restore_reconstructs_runtime_owned_worker_mutation_client() {
let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-source").unwrap();
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(17)); let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(17));
let backend = RuntimeWorkspaceBackendRef::Http { let backend = RuntimeWorkspaceBackendRef::Http {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
base_url: "https://server.invalid".to_string(), base_url: "https://server.invalid".to_string(),
@@ -2486,8 +2487,8 @@ mod tests {
fn create_request(_name: &str) -> CreateWorkerRequest { fn create_request(_name: &str) -> CreateWorkerRequest {
let bundle = test_bundle(); let bundle = test_bundle();
CreateWorkerRequest { CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::Builtin("builtin:companion".to_string()),
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded { profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
@@ -2590,7 +2591,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn runtime_provider_projects_only_explicit_live_canonical_grants() { async fn runtime_provider_projects_only_explicit_live_canonical_grants() {
let hub = Arc::new(RuntimeWorkerObservationHub::default()); let hub = Arc::new(RuntimeWorkerObservationHub::default());
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(7)); let worker_id = crate::identity::WorkerId::from_legacy_u64(7);
let worker_ref = WorkerRef::new(worker_id);
let shared_state = Arc::new(WorkerSharedState::new( let shared_state = Arc::new(WorkerSharedState::new(
"peer-worker".to_string(), "peer-worker".to_string(),
session_store::new_segment_id(), session_store::new_segment_id(),
@@ -2614,7 +2616,7 @@ mod tests {
sink: SegmentLogSink::new(), sink: SegmentLogSink::new(),
}, },
); );
let grant = crate::identity::RuntimeWorkerRef::new("runtime-1", "7"); let grant = crate::identity::RuntimeWorkerRef::new("runtime-1", worker_id.to_string());
let provider = RuntimeGrantedWorkerObservationProvider { let provider = RuntimeGrantedWorkerObservationProvider {
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
@@ -2628,7 +2630,7 @@ mod tests {
listed[0].subject, listed[0].subject,
WorkerObservationSubjectRef::RuntimeWorker { WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
worker_id: "7".to_string(), worker_id: worker_id.to_string(),
} }
); );
provider provider
@@ -2668,8 +2670,9 @@ mod tests {
} }
#[test] #[test]
fn runtime_worker_name_is_runtime_local() { fn runtime_worker_name_uses_workspace_worker_identity() {
let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1)); let worker_ref =
crate::identity::WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
let request = WorkerExecutionSpawnRequest { let request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
run_generation: 1, run_generation: 1,
@@ -2682,7 +2685,7 @@ mod tests {
assert_eq!( assert_eq!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request), ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
"worker-runtime-1" format!("worker-runtime-{}", request.worker_ref.worker_id)
); );
assert_ne!( assert_ne!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request), ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
@@ -2765,8 +2768,10 @@ mod tests {
async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() { async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let runtime_store_dir = root.path().join("runtime"); let runtime_store_dir = root.path().join("runtime");
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1)); let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
let worker_aggregate_dir = runtime_store_dir.join("workers/1"); let worker_aggregate_dir = runtime_store_dir
.join("workers")
.join(worker_ref.worker_id.to_string());
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref); let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
let session_id = session_store::new_session_id(); let session_id = session_store::new_session_id();
let manifest = manifest::WorkerManifest::from_toml(&format!( let manifest = manifest::WorkerManifest::from_toml(&format!(
@@ -2842,8 +2847,10 @@ mod tests {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let long_component = "embedded-workspace-store-segment".repeat(4); let long_component = "embedded-workspace-store-segment".repeat(4);
let runtime_store_dir = root.path().join(long_component); let runtime_store_dir = root.path().join(long_component);
let worker_ref = WorkerRef::new(crate::identity::WorkerId::new(1)); let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
let worker_aggregate_dir = runtime_store_dir.join("workers/1"); let worker_aggregate_dir = runtime_store_dir
.join("workers")
.join(worker_ref.worker_id.to_string());
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref); let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
let session_id = session_store::new_session_id(); let session_id = session_store::new_session_id();
let manifest = manifest::WorkerManifest::from_toml(&format!( let manifest = manifest::WorkerManifest::from_toml(&format!(
@@ -2880,7 +2887,10 @@ mod tests {
) )
.unwrap(); .unwrap();
let run_dir = runtime_store_dir.join("workers/1/runs/2"); let run_dir = runtime_store_dir
.join("workers")
.join(worker_ref.worker_id.to_string())
.join("runs/2");
let socket_path = run_dir.join("worker.sock"); let socket_path = run_dir.join("worker.sock");
assert!( assert!(
socket_path.as_os_str().as_encoded_bytes().len() > 107, socket_path.as_os_str().as_encoded_bytes().len() > 107,
@@ -2926,6 +2936,7 @@ mod tests {
let runtime_store_dir = root.path().join(long_component); let runtime_store_dir = root.path().join(long_component);
let runtime_options = crate::fs_store::FsRuntimeStoreOptions { let runtime_options = crate::fs_store::FsRuntimeStoreOptions {
root: runtime_store_dir.clone(), root: runtime_store_dir.clone(),
runtime_id: "test-runtime".to_string(),
display_name: Some("embedded".to_string()), display_name: Some("embedded".to_string()),
}; };
@@ -2946,7 +2957,10 @@ mod tests {
let mut request = create_request("embedded singleton"); let mut request = create_request("embedded singleton");
request.profile = ProfileSelector::Builtin("default".to_string()); request.profile = ProfileSelector::Builtin("default".to_string());
let worker = runtime.create_worker(request).unwrap(); let worker = runtime.create_worker(request).unwrap();
let first_run_socket = runtime_store_dir.join("workers/1/runs/1/worker.sock"); let first_run_socket = runtime_store_dir
.join("workers")
.join(worker.worker_id.to_string())
.join("runs/1/worker.sock");
assert!( assert!(
first_run_socket.as_os_str().as_encoded_bytes().len() > 107, first_run_socket.as_os_str().as_encoded_bytes().len() > 107,
"test path must exceed Linux sockaddr_un.sun_path capacity: {}", "test path must exceed Linux sockaddr_un.sun_path capacity: {}",
@@ -2998,7 +3012,10 @@ mod tests {
diagnostic.code == "worker_execution_restore_failed" diagnostic.code == "worker_execution_restore_failed"
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref) && diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
})); }));
let restored_run = runtime_store_dir.join("workers/1/runs/2"); let restored_run = runtime_store_dir
.join("workers")
.join(worker.worker_id.to_string())
.join("runs/2");
assert!(!restored_run.join("worker.sock").exists()); assert!(!restored_run.join("worker.sock").exists());
assert!(restored_run.join("worker.out.log").is_file()); assert!(restored_run.join("worker.out.log").is_file());
assert!(restored_run.join("worker.err.log").is_file()); assert!(restored_run.join("worker.err.log").is_file());
@@ -803,7 +803,7 @@ mod tests {
} }
fn worker_ref(sequence: u64) -> WorkerRef { fn worker_ref(sequence: u64) -> WorkerRef {
WorkerRef::new(WorkerId::generated(sequence)) WorkerRef::new(WorkerId::from_legacy_u64(sequence))
} }
#[test] #[test]
@@ -16,7 +16,7 @@ use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
}; };
use crate::spawn::registry::SpawnedWorkerRegistry; use crate::spawn::registry::{SpawnedWorkerRegistry, SubWorkerStopSummary};
use crate::worker::{ use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse, WorkspaceResponse,
@@ -138,14 +138,19 @@ impl WorkerControlService for WorkspaceWorkerControlService {
let registry = self.registry.as_ref().ok_or_else(|| { let registry = self.registry.as_ref().ok_or_else(|| {
WorkspaceClientError::Request("unknown Worker or permission not granted".to_string()) WorkspaceClientError::Request("unknown Worker or permission not granted".to_string())
})?; })?;
registry let summary = registry
.remove_internal(name) .remove_internal(name)
.await .await
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?; .map_err(|error| WorkspaceClientError::Request(error.to_string()))?
.ok_or_else(|| {
WorkspaceClientError::Request(
"unknown Worker or permission not granted".to_string(),
)
})?;
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::json!({ "subject": { "kind": "sub_worker", "name": name } }) body: serde_json::to_string(&summary)
.to_string(), .map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
}) })
} }
@@ -839,6 +844,15 @@ fn tool_output(
response.status, response.body response.status, response.body
))); )));
} }
if operation == WorkerOperation::Stop
&& let Ok(summary) = serde_json::from_str::<SubWorkerStopSummary>(&response.body)
{
return Ok(ToolOutput {
summary: render_subworker_stop_summary(&summary),
content: Some(response.body),
attachments: Vec::new(),
});
}
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("{} completed", operation.tool_name()), summary: format!("{} completed", operation.tool_name()),
content: Some(response.body), content: Some(response.body),
@@ -846,6 +860,37 @@ fn tool_output(
}) })
} }
fn render_subworker_stop_summary(summary: &SubWorkerStopSummary) -> String {
let tools = if summary.tool_counts.is_empty() {
"No tool calls".to_string()
} else {
summary
.tool_counts
.iter()
.map(|tool| format!("{} {}", tool.count, tool.name))
.collect::<Vec<_>>()
.join(", ")
};
let elapsed = format_elapsed(summary.elapsed_ms);
let changes = summary
.change_stat
.as_ref()
.map(|stat| format!("+{}/-{} Changes · ", stat.added, stat.deleted))
.unwrap_or_default();
format!("SubWorkerStop - done\n {tools}\n {changes}{elapsed}",)
}
fn format_elapsed(elapsed_ms: u64) -> String {
let seconds = elapsed_ms / 1_000;
let minutes = seconds / 60;
let seconds = seconds % 60;
if minutes > 0 {
format!("{minutes}m {seconds}s")
} else {
format!("{seconds}s")
}
}
fn definition<I: JsonSchema + 'static>( fn definition<I: JsonSchema + 'static>(
operation: WorkerOperation, operation: WorkerOperation,
control: Arc<dyn WorkerControlService>, control: Arc<dyn WorkerControlService>,
@@ -1252,6 +1297,47 @@ mod tests {
assert!(client.removals.lock().unwrap().is_empty()); assert!(client.removals.lock().unwrap().is_empty());
} }
#[test]
fn subworker_stop_output_is_compact_and_keeps_typed_evidence() {
let summary = SubWorkerStopSummary {
session_id: "session-1".to_string(),
display_name: "research".to_string(),
outcome: crate::spawn::registry::SubWorkerFinalOutcome::Done,
elapsed_ms: 78_000,
tool_counts: vec![
crate::spawn::registry::SubWorkerToolCount {
name: "Read".to_string(),
count: 26,
},
crate::spawn::registry::SubWorkerToolCount {
name: "Grep".to_string(),
count: 5,
},
],
change_stat: Some(crate::spawn::registry::SubWorkerChangeStat {
added: 215,
deleted: 148,
source: "tracked_write_edit_tools".to_string(),
}),
};
let response = WorkspaceResponse {
status: 200,
body: serde_json::to_string(&summary).unwrap(),
};
let output = tool_output(WorkerOperation::Stop, response).unwrap();
assert_eq!(
output.summary,
"SubWorkerStop - done\n 26 Read, 5 Grep\n +215/-148 Changes · 1m 18s"
);
assert_eq!(
serde_json::from_str::<SubWorkerStopSummary>(output.content.as_deref().unwrap())
.unwrap(),
summary
);
}
#[test] #[test]
fn worker_inputs_reject_paths_and_parent_traversal() { fn worker_inputs_reject_paths_and_parent_traversal() {
assert!(authority_id("https://runtime.example", "runtime_id").is_err()); assert!(authority_id("https://runtime.example", "runtime_id").is_err());
@@ -1781,6 +1781,7 @@ provider = "github"
assert!(request.contains("\"title\":\"HTTP ticket\"")); assert!(request.contains("\"title\":\"HTTP ticket\""));
let response_body = serde_json::to_string(&TicketRef { let response_body = serde_json::to_string(&TicketRef {
id: "01TEST".to_string(), id: "01TEST".to_string(),
human_key: None,
slug: "http-ticket".to_string(), slug: "http-ticket".to_string(),
status: ticket::TicketStatus::Open, status: ticket::TicketStatus::Open,
}) })
+46 -1
View File
@@ -294,6 +294,8 @@ pub(crate) struct InternalWorkerSessionHandle {
last_error: Arc<Mutex<Option<String>>>, last_error: Arc<Mutex<Option<String>>>,
child_registry: Option<Arc<SpawnedWorkerRegistry>>, child_registry: Option<Arc<SpawnedWorkerRegistry>>,
sink: SegmentLogSink, sink: SegmentLogSink,
#[cfg(test)]
fail_stop: Arc<std::sync::atomic::AtomicBool>,
} }
impl InternalWorkerSessionHandle { impl InternalWorkerSessionHandle {
@@ -319,6 +321,9 @@ impl InternalWorkerSessionHandle {
#[cfg(test)] #[cfg(test)]
pub(crate) fn publish_test_entry(&self, entry: LogEntry) { pub(crate) fn publish_test_entry(&self, entry: LogEntry) {
self.store
.append(self.session_id, self.segment_id, &entry)
.expect("append test Internal Worker entry");
self.sink.publish(entry); self.sink.publish(entry);
} }
@@ -419,7 +424,23 @@ impl InternalWorkerSessionHandle {
} }
} }
#[cfg(test)]
pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) {
self.status
.store(status.encode(), std::sync::atomic::Ordering::Release);
}
#[cfg(test)]
pub(crate) fn force_stop_failure(&self) {
self.fail_stop
.store(true, std::sync::atomic::Ordering::Release);
}
pub(crate) async fn stop(&self) -> Result<(), InternalWorkerSessionError> { pub(crate) async fn stop(&self) -> Result<(), InternalWorkerSessionError> {
#[cfg(test)]
if self.fail_stop.load(std::sync::atomic::Ordering::Acquire) {
return Err(InternalWorkerSessionError::Unavailable);
}
let prior = self.status.swap( let prior = self.status.swap(
InternalWorkerSessionStatus::Stopping.encode(), InternalWorkerSessionStatus::Stopping.encode(),
std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::AcqRel,
@@ -555,6 +576,7 @@ pub(crate) async fn prepare_internal_worker_session(
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone()); spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
let alerter = Alerter::new(event_tx.clone()); let alerter = Alerter::new(event_tx.clone());
let in_flight = InFlightEvents::new(event_tx.clone()); let in_flight = InFlightEvents::new(event_tx.clone());
let actor_in_flight = in_flight.clone();
worker.attach_alerter(alerter.clone()); worker.attach_alerter(alerter.clone());
worker.attach_event_tx(event_tx.clone()); worker.attach_event_tx(event_tx.clone());
worker.attach_in_flight_events(in_flight.clone()); worker.attach_in_flight_events(in_flight.clone());
@@ -581,12 +603,15 @@ pub(crate) async fn prepare_internal_worker_session(
last_error: last_error.clone(), last_error: last_error.clone(),
child_registry, child_registry,
sink, sink,
#[cfg(test)]
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}; };
tokio::spawn(async move { tokio::spawn(async move {
while let Some(command) = command_rx.recv().await { while let Some(command) = command_rx.recv().await {
match command { match command {
InternalWorkerSessionCommand::Run(input) => { InternalWorkerSessionCommand::Run(input) => {
actor_in_flight.clear();
let cancel_sender = worker.engine_mut().cancel_sender(); let cancel_sender = worker.engine_mut().cancel_sender();
let mut run = std::pin::pin!(worker.run_text(&input)); let mut run = std::pin::pin!(worker.run_text(&input));
loop { loop {
@@ -599,6 +624,7 @@ pub(crate) async fn prepare_internal_worker_session(
Some(error.to_string()), Some(error.to_string()),
), ),
}; };
actor_in_flight.clear();
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error { if let Some(message) = error {
*last_error.lock().unwrap() = Some(message.clone()); *last_error.lock().unwrap() = Some(message.clone());
@@ -622,6 +648,7 @@ pub(crate) async fn prepare_internal_worker_session(
Some(InternalWorkerSessionCommand::Stop(done)) => { Some(InternalWorkerSessionCommand::Stop(done)) => {
let _ = cancel_sender.send(()).await; let _ = cancel_sender.send(()).await;
let _ = (&mut run).await; let _ = (&mut run).await;
actor_in_flight.clear();
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release); status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused }); let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
@@ -634,6 +661,7 @@ pub(crate) async fn prepare_internal_worker_session(
} }
None => { None => {
let _ = cancel_sender.send(()).await; let _ = cancel_sender.send(()).await;
actor_in_flight.clear();
return; return;
} }
} }
@@ -642,6 +670,7 @@ pub(crate) async fn prepare_internal_worker_session(
} }
} }
InternalWorkerSessionCommand::Stop(done) => { InternalWorkerSessionCommand::Stop(done) => {
actor_in_flight.clear();
status.store( status.store(
InternalWorkerSessionStatus::Stopped.encode(), InternalWorkerSessionStatus::Stopped.encode(),
std::sync::atomic::Ordering::Release, std::sync::atomic::Ordering::Release,
@@ -656,6 +685,7 @@ pub(crate) async fn prepare_internal_worker_session(
} }
} }
} }
actor_in_flight.clear();
}); });
Ok(handle) Ok(handle)
@@ -883,8 +913,17 @@ pub(crate) fn test_internal_worker_session(
let session_id = session_store::new_session_id(); let session_id = session_store::new_session_id();
let segment_id = session_store::new_segment_id(); let segment_id = session_store::new_segment_id();
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1);
tokio::spawn(async move { while command_rx.recv().await.is_some() {} });
let (event_tx, _) = broadcast::channel(256); let (event_tx, _) = broadcast::channel(256);
let command_event_tx = event_tx.clone();
tokio::spawn(async move {
while let Some(command) = command_rx.recv().await {
if let InternalWorkerSessionCommand::Stop(done_tx) = command {
let _ = command_event_tx.send(Event::Shutdown);
let _ = done_tx.send(());
break;
}
}
});
let sink = SegmentLogSink::new(); let sink = SegmentLogSink::new();
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone()); spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
let handle = InternalWorkerSessionHandle { let handle = InternalWorkerSessionHandle {
@@ -902,6 +941,7 @@ pub(crate) fn test_internal_worker_session(
last_error: Arc::new(Mutex::new(None)), last_error: Arc::new(Mutex::new(None)),
child_registry: None, child_registry: None,
sink, sink,
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}; };
(handle, event_tx) (handle, event_tx)
} }
@@ -1068,6 +1108,10 @@ permission = "write"
handle.wait_until_idle().await, handle.wait_until_idle().await,
InternalWorkerSessionStatus::Idle InternalWorkerSessionStatus::Idle
); );
handle
.in_flight
.tool_call_start("stale-call".to_string(), "Read".to_string());
assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1);
let entries_after_first = handle.entries().len(); let entries_after_first = handle.entries().len();
assert!(entries_after_first >= 4); assert!(entries_after_first >= 4);
handle.send("follow-up").await.expect("send follow-up turn"); handle.send("follow-up").await.expect("send follow-up turn");
@@ -1075,6 +1119,7 @@ permission = "write"
handle.wait_until_idle().await, handle.wait_until_idle().await,
InternalWorkerSessionStatus::Idle InternalWorkerSessionStatus::Idle
); );
assert!(handle.protocol_snapshot().in_flight.blocks.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(handle.entries().len() > entries_after_first); assert!(handle.entries().len() > entries_after_first);
+18 -11
View File
@@ -170,20 +170,27 @@ impl Tool for SubWorkerStopTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let input: NameInput = serde_json::from_str(input_json) let input: NameInput = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?; .map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?;
if let Some(record) = self.registry.get_internal(&input.name) { if let Some(summary) = self
record.session.stop().await.map_err(|error| { .registry
ToolError::ExecutionFailed(format!("stop `{}`: {error}", input.name)) .remove_internal(&input.name)
})?; .await
self.registry .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
.remove_internal(&input.name) {
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
return Ok(ToolOutput { return Ok(ToolOutput {
summary: format!( summary: format!(
"stopped worker `{}` and reclaimed delegated scope", "SubWorkerStop - done\n {} tool kind{}\n {}ms",
input.name summary.tool_counts.len(),
if summary.tool_counts.len() == 1 {
""
} else {
"s"
},
summary.elapsed_ms,
),
content: Some(
serde_json::to_string(&summary)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
), ),
content: None,
attachments: Vec::new(), attachments: Vec::new(),
}); });
} }
+296 -8
View File
@@ -7,17 +7,20 @@
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny //! Parent registry drop closes all session handles and synchronously returns delegated Write deny
//! rules to the parent scope. //! rules to the parent scope.
use std::collections::HashSet; use std::collections::{BTreeMap, HashSet};
use std::io; use std::io;
use std::sync::{ use std::sync::{
Arc, Mutex, Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
}; };
use std::time::Instant;
use serde::{Deserialize, Serialize};
use manifest::{Permission, ScopeRule, SharedScope}; use manifest::{Permission, ScopeRule, SharedScope};
use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot}; use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot};
use session_store::{ use session_store::{
WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError, LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
}; };
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::warn; use tracing::warn;
@@ -27,6 +30,39 @@ use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibili
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use crate::runtime::worker_allocation; use crate::runtime::worker_allocation;
const STOP_SUMMARY_TOOL_LIMIT: usize = 16;
const STOP_SUMMARY_TOOL_NAME_LIMIT: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SubWorkerFinalOutcome {
Done,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SubWorkerToolCount {
pub name: String,
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SubWorkerChangeStat {
pub added: u64,
pub deleted: u64,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SubWorkerStopSummary {
pub session_id: String,
pub display_name: String,
pub outcome: SubWorkerFinalOutcome,
pub elapsed_ms: u64,
pub tool_counts: Vec<SubWorkerToolCount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_stat: Option<SubWorkerChangeStat>,
}
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct InternalSpawnedWorkerRecord { pub(crate) struct InternalSpawnedWorkerRecord {
pub worker_name: String, pub worker_name: String,
@@ -35,8 +71,13 @@ pub(crate) struct InternalSpawnedWorkerRecord {
#[cfg(test)] #[cfg(test)]
pub installed_tools: Arc<[String]>, pub installed_tools: Arc<[String]>,
pub session: InternalWorkerSessionHandle, pub session: InternalWorkerSessionHandle,
change_tracker: Option<tools::Tracker>,
started_at: Instant,
stop_lock: Arc<tokio::sync::Mutex<()>>,
scope_reclaimed: Arc<AtomicBool>, scope_reclaimed: Arc<AtomicBool>,
protocol_revision: Arc<AtomicU64>, protocol_revision: Arc<AtomicU64>,
protocol_emit_lock: Arc<Mutex<()>>,
protocol_terminal: Arc<AtomicBool>,
forwarding_started: Arc<AtomicBool>, forwarding_started: Arc<AtomicBool>,
} }
@@ -47,6 +88,7 @@ impl InternalSpawnedWorkerRecord {
workdir_delegation: WorkdirDelegation, workdir_delegation: WorkdirDelegation,
#[cfg(test)] installed_tools: Vec<String>, #[cfg(test)] installed_tools: Vec<String>,
session: InternalWorkerSessionHandle, session: InternalWorkerSessionHandle,
change_tracker: Option<tools::Tracker>,
) -> Self { ) -> Self {
Self { Self {
worker_name, worker_name,
@@ -55,12 +97,64 @@ impl InternalSpawnedWorkerRecord {
#[cfg(test)] #[cfg(test)]
installed_tools: installed_tools.into(), installed_tools: installed_tools.into(),
session, session,
change_tracker,
started_at: Instant::now(),
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
scope_reclaimed: Arc::new(AtomicBool::new(false)), scope_reclaimed: Arc::new(AtomicBool::new(false)),
protocol_revision: Arc::new(AtomicU64::new(0)), protocol_revision: Arc::new(AtomicU64::new(0)),
protocol_emit_lock: Arc::new(Mutex::new(())),
protocol_terminal: Arc::new(AtomicBool::new(false)),
forwarding_started: Arc::new(AtomicBool::new(false)), forwarding_started: Arc::new(AtomicBool::new(false)),
} }
} }
fn stop_summary(&self) -> SubWorkerStopSummary {
let mut counts = BTreeMap::<String, u64>::new();
for entry in self.session.entries() {
if let session_store::LogEntry::AssistantItem {
item: LoggedItem::ToolCall { name, .. },
..
} = entry
{
let count = counts.entry(bounded_tool_name(&name)).or_default();
*count = count.saturating_add(1);
}
}
let mut tool_counts = counts
.into_iter()
.map(|(name, count)| SubWorkerToolCount { name, count })
.collect::<Vec<_>>();
tool_counts.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.name.cmp(&right.name))
});
tool_counts.truncate(STOP_SUMMARY_TOOL_LIMIT);
let change_stat = self.change_tracker.as_ref().and_then(|tracker| {
let stat = tracker.change_stat();
(stat.added > 0 || stat.deleted > 0).then(|| SubWorkerChangeStat {
added: stat.added,
deleted: stat.deleted,
source: "tracked_write_edit_tools".to_string(),
})
});
SubWorkerStopSummary {
session_id: self.session.session_id_string(),
display_name: self.worker_name.clone(),
outcome: SubWorkerFinalOutcome::Done,
elapsed_ms: self
.started_at
.elapsed()
.as_millis()
.min(u128::from(u64::MAX)) as u64,
tool_counts,
change_stat,
}
}
fn claim_scope_reclaim(&self) -> bool { fn claim_scope_reclaim(&self) -> bool {
!self.scope_reclaimed.swap(true, Ordering::AcqRel) !self.scope_reclaimed.swap(true, Ordering::AcqRel)
} }
@@ -277,12 +371,20 @@ impl SpawnedWorkerRegistry {
}; };
let worker = record.protocol_ref(Some(parent_session_id)); let worker = record.protocol_ref(Some(parent_session_id));
let protocol_revision = record.protocol_revision.clone(); let protocol_revision = record.protocol_revision.clone();
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events(); let mut child_rx = record.session.subscribe_events();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match child_rx.recv().await { match child_rx.recv().await {
Ok(event) => { Ok(event) => {
let shutdown = matches!(event, Event::Shutdown); let shutdown = matches!(event, Event::Shutdown);
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker { let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
@@ -294,6 +396,12 @@ impl SpawnedWorkerRegistry {
} }
} }
Err(broadcast::error::RecvError::Lagged(skipped)) => { Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker { let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(), worker: worker.clone(),
@@ -385,13 +493,34 @@ impl SpawnedWorkerRegistry {
result result
} }
/// Stop one direct Internal SubWorker and discard its registry/scope state.
///
/// The child actor must acknowledge its stop before the registry is removed.
/// After scope reclamation and removal, `InternalWorkerRemoved` is published
/// exactly once as the parent-stream terminal fence. Callers only receive
/// `Done` after all authoritative cleanup succeeds.
pub(crate) async fn remove_internal( pub(crate) async fn remove_internal(
&self, &self,
worker_name: &str, worker_name: &str,
) -> io::Result<Option<InternalSpawnedWorkerRecord>> { ) -> io::Result<Option<SubWorkerStopSummary>> {
if let Some(record) = self.get_internal(worker_name) { let Some(record) = self.get_internal(worker_name) else {
self.reclaim_record_scope(&record)?; return Ok(None);
};
let _stop_guard = record.stop_lock.lock().await;
let still_registered = self.get_internal(worker_name).is_some_and(|current| {
current.session.session_id_string() == record.session.session_id_string()
});
if !still_registered {
return Ok(None);
} }
record
.session
.stop()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
let summary = record.stop_summary();
self.reclaim_record_scope(&record)?;
let removed = let removed =
{ {
let mut records = self.internal_records.lock().map_err(|_| { let mut records = self.internal_records.lock().map_err(|_| {
@@ -402,14 +531,41 @@ impl SpawnedWorkerRegistry {
})?; })?;
let removed = records let removed = records
.iter() .iter()
.position(|record| record.worker_name == worker_name) .position(|candidate| {
candidate.worker_name == worker_name
&& candidate.session.session_id_string()
== record.session.session_id_string()
})
.map(|index| records.remove(index)); .map(|index| records.remove(index));
if removed.is_some() { if removed.is_some() {
names.remove(worker_name); names.remove(worker_name);
} }
removed removed
}; };
Ok(removed) if removed.is_some() {
self.publish_internal_removal(&record);
}
Ok(removed.map(|_| summary))
}
fn publish_internal_removal(&self, record: &InternalSpawnedWorkerRecord) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
return;
}
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
else {
return;
};
let _emit_guard = record
.protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
record.protocol_terminal.store(true, Ordering::Release);
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorkerRemoved {
worker: record.protocol_ref(Some(parent_session_id)),
revision,
});
} }
} }
@@ -508,6 +664,17 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result<SpawnedWor
}) })
} }
fn bounded_tool_name(name: &str) -> String {
let mut bounded = name
.chars()
.take(STOP_SUMMARY_TOOL_NAME_LIMIT)
.collect::<String>();
if name.chars().count() > STOP_SUMMARY_TOOL_NAME_LIMIT {
bounded.push('…');
}
bounded
}
fn store_error_to_io(error: WorkerStoreError) -> io::Error { fn store_error_to_io(error: WorkerStoreError) -> io::Error {
io::Error::other(error) io::Error::other(error)
} }
@@ -520,7 +687,7 @@ mod tests {
use session_store::LogEntry; use session_store::LogEntry;
use super::*; use super::*;
use crate::internal_worker::test_internal_worker_session; use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session};
fn registry() -> Arc<SpawnedWorkerRegistry> { fn registry() -> Arc<SpawnedWorkerRegistry> {
let scope = Scope::from_config(&ScopeConfig { let scope = Scope::from_config(&ScopeConfig {
@@ -577,6 +744,7 @@ mod tests {
delegation, delegation,
Vec::new(), Vec::new(),
session, session,
None,
), ),
sender, sender,
) )
@@ -669,4 +837,124 @@ mod tests {
); );
assert!(registry.internal_worker_snapshots().is_empty()); assert!(registry.internal_worker_snapshots().is_empty());
} }
fn install_record(registry: &SpawnedWorkerRegistry, record: InternalSpawnedWorkerRecord) {
registry
.internal_names
.lock()
.unwrap()
.insert(record.worker_name.clone());
registry.internal_records.lock().unwrap().push(record);
}
#[tokio::test]
async fn stop_removes_internal_worker_and_returns_bounded_summary() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(32);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let tracker = tools::Tracker::new();
tracker.record_change(12, 4);
let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
record.change_tracker = Some(tracker);
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
record.session.publish_test_entry(LogEntry::AssistantItem {
ts: index as u64,
item: LoggedItem::ToolCall {
call_id: format!("call-{index}"),
name: name.to_string(),
arguments: "{}".to_string(),
},
});
}
registry.start_protocol_forwarding(record.clone());
install_record(&registry, record);
let summary = registry.remove_internal("child").await.unwrap().unwrap();
assert_eq!(summary.display_name, "child");
assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done);
assert_eq!(
summary.tool_counts,
vec![
SubWorkerToolCount {
name: "Read".to_string(),
count: 2,
},
SubWorkerToolCount {
name: "Grep".to_string(),
count: 1,
},
]
);
assert_eq!(
summary.change_stat,
Some(SubWorkerChangeStat {
added: 12,
deleted: 4,
source: "tracked_write_edit_tools".to_string(),
})
);
assert!(registry.get_internal("child").is_none());
let terminal_revision = loop {
if let Event::InternalWorkerRemoved { worker, revision } =
parent_rx.recv().await.unwrap()
{
assert_eq!(worker.session_id, summary.session_id);
assert!(revision > 0);
break revision;
}
};
assert!(registry.remove_internal("child").await.unwrap().is_none());
while let Ok(Ok(event)) =
tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()).await
{
assert!(!matches!(event, Event::InternalWorkerRemoved { .. }));
if let Event::InternalWorker { revision, .. } = event {
assert!(revision > terminal_revision);
}
}
}
#[tokio::test]
async fn running_worker_is_stopped_before_removal() {
let registry = registry();
let (record, _events) = record("running", InternalWorkerVisibility::ParentClient).await;
record
.session
.force_status(InternalWorkerSessionStatus::Running);
install_record(&registry, record);
let summary = registry.remove_internal("running").await.unwrap().unwrap();
assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done);
assert!(registry.get_internal("running").is_none());
}
#[tokio::test]
async fn stop_failure_keeps_registry_and_emits_no_removal() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(8);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
record.session.force_stop_failure();
install_record(&registry, record);
let error = registry.remove_internal("child").await.unwrap_err();
assert!(error.to_string().contains("unavailable"));
assert!(registry.get_internal("child").is_some());
assert!(matches!(
parent_rx.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn read_only_summary_omits_unavailable_change_stat() {
let tracker = tools::Tracker::new();
let (mut record, _events) = record("reader", InternalWorkerVisibility::ParentClient).await;
record.change_tracker = Some(tracker);
assert_eq!(record.stop_summary().change_stat, None);
}
} }
+2
View File
@@ -481,6 +481,7 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| { .map_err(|error| {
ToolError::ExecutionFailed(format!("install Internal Worker features: {error}")) ToolError::ExecutionFailed(format!("install Internal Worker features: {error}"))
})?; })?;
let child_change_tracker = child.tracker().cloned();
#[cfg(test)] #[cfg(test)]
let installed_tools = child let installed_tools = child
.engine() .engine()
@@ -587,6 +588,7 @@ impl Tool for SubWorkerSpawnTool {
#[cfg(test)] #[cfg(test)]
installed_tools, installed_tools,
session.clone(), session.clone(),
child_change_tracker,
); );
if let Err(error) = name_reservation.commit(record) { if let Err(error) = name_reservation.commit(record) {
let _ = session.stop().await; let _ = session.stop().await;
+200 -64
View File
@@ -20,12 +20,13 @@ use crate::records::{
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
TicketQueryResponse, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body, TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage,
truncate_body, validate_project_id, summarize_body, truncate_body, validate_project_id,
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
WorkspaceResourceKind,
}; };
use crate::{Error, Result}; use crate::{Error, Result};
@@ -227,10 +228,24 @@ impl SqliteWorkspaceAuthority {
self self
} }
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> { fn human_key(&self, kind: WorkspaceResourceKind, resource_id: &str) -> Result<String> {
self.store self.store
.get_objective(&self.workspace_id, id)? .resource_human_key(&self.workspace_id, kind, resource_id)?
.ok_or_else(|| unknown_objective_error(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> { 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)) .filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
.map(|ticket| ObjectiveLinkedTicketSummary { .map(|ticket| ObjectiveLinkedTicketSummary {
id: ticket.id, id: ticket.id,
human_key: ticket.human_key,
title: ticket.title, title: ticket.title,
state: ticket.state, state: ticket.state,
}) })
@@ -288,6 +304,7 @@ impl SqliteWorkspaceAuthority {
.unwrap_or("none") .unwrap_or("none")
); );
Ok(ObjectiveDetail { Ok(ObjectiveDetail {
human_key: self.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
id: record.objective_id, id: record.objective_id,
title: record.title, title: record.title,
state: record.state, state: record.state,
@@ -681,11 +698,20 @@ impl SqliteWorkspaceAuthority {
}) })
} }
fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result<TicketDetail> { fn read_ticket_detail(
validate_project_id(id)?; &self,
let ticket = self reference: &str,
.ticket_backend request: TicketShowRequest,
.show(TicketIdOrSlug::Id(id.to_string()))?; ) -> Result<TicketDetail> {
let id = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
reference,
)?
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())))?;
let ticket = self.ticket_backend.show(TicketIdOrSlug::Id(id))?;
self.ticket_detail_from_ticket(ticket, request) self.ticket_detail_from_ticket(ticket, request)
} }
@@ -718,12 +744,16 @@ impl SqliteWorkspaceAuthority {
.store .store
.list_objectives_for_ticket(&self.workspace_id, id, 1_000)? .list_objectives_for_ticket(&self.workspace_id, id, 1_000)?
.into_iter() .into_iter()
.map(|objective| ObjectiveLinkSummary { .map(|objective| {
id: objective.objective_id, Ok::<_, Error>(ObjectiveLinkSummary {
title: objective.title, human_key: self
state: objective.state, .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 let implementation_reports = ticket
.events .events
.iter() .iter()
@@ -734,11 +764,20 @@ impl SqliteWorkspaceAuthority {
let current_assignment = self let current_assignment = self
.store .store
.get_current_ticket_worker_assignment(&self.workspace_id, id)? .get_current_ticket_worker_assignment(&self.workspace_id, id)?
.map(|assignment| TicketAssignmentSummary { .map(|assignment| {
assignment_id: assignment.assignment_id, let worker_human_key = self.store.resource_human_key(
runtime_id: assignment.worker.runtime_id, &self.workspace_id,
worker_id: assignment.worker.worker_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) { let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
Ok(request) => { Ok(request) => {
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| { let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
@@ -763,8 +802,41 @@ impl SqliteWorkspaceAuthority {
.and_then(|event| event.attributes.get("event_id").cloned()) .and_then(|event| event.attributes.get("event_id").cloned())
.or_else(|| ticket.meta.updated_at.clone()) .or_else(|| ticket.meta.updated_at.clone())
.unwrap_or_else(|| format!("{}:0", ticket.meta.id)); .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 { Ok(TicketDetail {
id: ticket.meta.id, id: ticket.meta.id,
human_key,
title: ticket.meta.title, title: ticket.meta.title,
state: ticket.meta.workflow_state.as_str().to_string(), state: ticket.meta.workflow_state.as_str().to_string(),
readiness: ticket.meta.readiness, readiness: ticket.meta.readiness,
@@ -797,7 +869,7 @@ impl SqliteWorkspaceAuthority {
.into_iter() .into_iter()
.map(|artifact| artifact.relative_path.display().to_string()) .map(|artifact| artifact.relative_path.display().to_string())
.collect(), .collect(),
relations: ticket.relations.into(), relations,
linked_objectives, linked_objectives,
implementation_reports, implementation_reports,
current_assignment, current_assignment,
@@ -820,7 +892,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
.map(|item| { .map(|item| {
let projection = let projection =
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); 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, id: item.summary.id,
title: item.summary.title, title: item.summary.title,
state: item.summary.workflow_state.as_str().to_string(), state: item.summary.workflow_state.as_str().to_string(),
@@ -831,9 +907,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
workspace_action_priority: workspace_action_priority_name(projection.priority) workspace_action_priority: workspace_action_priority_name(projection.priority)
.to_string(), .to_string(),
record_source: "sqlite_yoi_ticket".to_string(), record_source: "sqlite_yoi_ticket".to_string(),
} })
}) })
.collect(); .collect::<Result<Vec<_>>>()?;
Ok(ProjectRecordList { Ok(ProjectRecordList {
items, items,
invalid_records: Vec::new(), invalid_records: Vec::new(),
@@ -874,7 +950,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
.items .items
.into_iter() .into_iter()
.map(ticket_summary_from_sqlite_item) .map(ticket_summary_from_sqlite_item)
.collect::<Vec<_>>(); .collect::<Result<Vec<_>>>()?;
let next_cursor = page let next_cursor = page
.next .next
.map(|position| make_ticket_summary_cursor(&fingerprint, position)); .map(|position| make_ticket_summary_cursor(&fingerprint, position));
@@ -913,7 +989,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
let authoritative = self let authoritative = self
.ticket_backend .ticket_backend
.show(TicketIdOrSlug::Id(ticket_id.clone()))?; .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_body = authoritative.document.body.clone();
let authoritative_events = authoritative.events.clone(); let authoritative_events = authoritative.events.clone();
let detail = self.ticket_detail_from_ticket( let detail = self.ticket_detail_from_ticket(
@@ -987,6 +1063,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
.map(|link| link.ticket_id) .map(|link| link.ticket_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
items.push(ObjectiveSummary { items.push(ObjectiveSummary {
human_key: self
.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
id: record.objective_id, id: record.objective_id,
title: record.title, title: record.title,
state: record.state, state: record.state,
@@ -1032,6 +1110,8 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let body_md = record.body_md.clone(); let body_md = record.body_md.clone();
let objective = ObjectiveSummary { let objective = ObjectiveSummary {
human_key: self
.human_key(WorkspaceResourceKind::Objective, &record.objective_id)?,
id: record.objective_id, id: record.objective_id,
title: record.title, title: record.title,
state: record.state, state: record.state,
@@ -1076,15 +1156,21 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
}) })
} }
fn objective(&self, id: &str) -> Result<ObjectiveDetail> { fn objective(&self, reference: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?; let record = self.objective_record(reference)?;
let record = self.objective_record(id)?;
self.objective_detail_from_record(record) self.objective_detail_from_record(record)
} }
fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result<ObjectiveDetail> { fn show_objective(
let mut detail = self.objective(id)?; &self,
let all_events = self.store.list_objective_events(&self.workspace_id, id)?; reference: &str,
query: ObjectiveShowRequest,
) -> Result<ObjectiveDetail> {
let mut detail = self.objective(reference)?;
let objective_id = detail.id.clone();
let all_events = self
.store
.list_objective_events(&self.workspace_id, &objective_id)?;
let event_limit = query let event_limit = query
.event_limit .event_limit
.unwrap_or(TICKET_EVENT_LIMIT) .unwrap_or(TICKET_EVENT_LIMIT)
@@ -1169,9 +1255,12 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
self.objective(&objective_id) self.objective(&objective_id)
} }
fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result<ObjectiveDetail> { fn edit_objective(
validate_project_id(id)?; &self,
let mut record = self.objective_record(id)?; reference: &str,
input: ObjectiveEditInput,
) -> Result<ObjectiveDetail> {
let mut record = self.objective_record(reference)?;
let mut changed = false; let mut changed = false;
if let Some(title) = input.title { if let Some(title) = input.title {
validate_objective_title(&title)?; validate_objective_title(&title)?;
@@ -1217,60 +1306,85 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
)); ));
} }
record.updated_at = now_rfc3339(); record.updated_at = now_rfc3339();
let objective_id = record.objective_id.clone();
self.store.upsert_objective(&record)?; self.store.upsert_objective(&record)?;
self.insert_objective_event(id, "edit", None)?; self.insert_objective_event(&objective_id, "edit", None)?;
self.objective(id) self.objective(&objective_id)
} }
fn set_objective_state(&self, id: &str, state: &str) -> Result<ObjectiveDetail> { fn set_objective_state(&self, reference: &str, state: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
validate_objective_state(state)?; validate_objective_state(state)?;
let mut record = self.objective_record(id)?; let mut record = self.objective_record(reference)?;
record.state = state.trim().to_string(); record.state = state.trim().to_string();
record.updated_at = now_rfc3339(); record.updated_at = now_rfc3339();
let objective_id = record.objective_id.clone();
self.store.upsert_objective(&record)?; self.store.upsert_objective(&record)?;
self.insert_objective_event(id, "state", Some(&record.state))?; self.insert_objective_event(&objective_id, "state", Some(&record.state))?;
self.objective(id) self.objective(&objective_id)
} }
fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> { fn link_objective_ticket(
validate_project_id(id)?; &self,
validate_project_id(ticket_id)?; objective_reference: &str,
let _record = self.objective_record(id)?; ticket_reference: &str,
) -> Result<ObjectiveDetail> {
let objective_id = self.objective_record(objective_reference)?.objective_id;
let ticket_id = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket_reference,
)?
.ok_or_else(|| {
Error::Ticket(ticket::TicketError::NotFound(ticket_reference.to_string()))
})?;
let now = now_rfc3339(); let now = now_rfc3339();
let mut links = self let mut links = self
.store .store
.list_objective_ticket_links(&self.workspace_id, id)?; .list_objective_ticket_links(&self.workspace_id, &objective_id)?;
if !links.iter().any(|link| link.ticket_id == ticket_id) { if !links.iter().any(|link| link.ticket_id == ticket_id) {
links.push(ObjectiveTicketLinkRecord { links.push(ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
objective_id: id.to_string(), objective_id: objective_id.clone(),
ticket_id: ticket_id.to_string(), ticket_id: ticket_id.clone(),
kind: "linked".to_string(), kind: "linked".to_string(),
created_at: now, created_at: now,
}); });
self.store self.store
.replace_objective_ticket_links(&self.workspace_id, id, &links)?; .replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?;
self.insert_objective_event(id, "link_ticket", Some(ticket_id))?; self.insert_objective_event(&objective_id, "link_ticket", Some(&ticket_id))?;
} }
self.objective(id) self.objective(&objective_id)
} }
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> { fn unlink_objective_ticket(
validate_project_id(id)?; &self,
validate_project_id(ticket_id)?; objective_reference: &str,
let _record = self.objective_record(id)?; ticket_reference: &str,
) -> Result<ObjectiveDetail> {
let objective_id = self.objective_record(objective_reference)?.objective_id;
let ticket_id = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket_reference,
)?
.ok_or_else(|| {
Error::Ticket(ticket::TicketError::NotFound(ticket_reference.to_string()))
})?;
let mut links = self let mut links = self
.store .store
.list_objective_ticket_links(&self.workspace_id, id)?; .list_objective_ticket_links(&self.workspace_id, &objective_id)?;
let original_len = links.len(); let original_len = links.len();
links.retain(|link| link.ticket_id != ticket_id); links.retain(|link| link.ticket_id != ticket_id);
if links.len() != original_len { if links.len() != original_len {
self.store self.store
.replace_objective_ticket_links(&self.workspace_id, id, &links)?; .replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?;
self.insert_objective_event(id, "unlink_ticket", Some(ticket_id))?; self.insert_objective_event(&objective_id, "unlink_ticket", Some(&ticket_id))?;
} }
self.objective(id) self.objective(&objective_id)
} }
} }
@@ -2023,6 +2137,7 @@ fn ticket_query_item(
} }
TicketQueryItem { TicketQueryItem {
id: summary.id, id: summary.id,
human_key: summary.human_key,
title: summary.title, title: summary.title,
state: summary.state, state: summary.state,
readiness: detail.readiness.clone(), readiness: detail.readiness.clone(),
@@ -2362,9 +2477,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 { let summary = ticket::TicketSummary {
id: ticket.meta.id.clone(), id: ticket.meta.id.clone(),
human_key: ticket.meta.human_key.clone(),
slug: ticket.meta.slug.clone(), slug: ticket.meta.slug.clone(),
title: ticket.meta.title.clone(), title: ticket.meta.title.clone(),
status: ticket.meta.status.clone(), status: ticket.meta.status.clone(),
@@ -2384,9 +2500,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); 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, id: item.summary.id,
title: item.summary.title, title: item.summary.title,
state: item.summary.workflow_state.as_str().to_string(), state: item.summary.workflow_state.as_str().to_string(),
@@ -2396,7 +2518,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> TicketSummary
queued_at: item.summary.queued_at, queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(), workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
record_source: "sqlite_yoi_ticket".to_string(), record_source: "sqlite_yoi_ticket".to_string(),
} })
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -2828,6 +2950,8 @@ mod tests {
assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready"); assert_eq!(tickets.items[0].state, "ready");
assert_eq!(tickets.items[0].workspace_action_priority, "background"); assert_eq!(tickets.items[0].workspace_action_priority, "background");
let ticket_by_key = authority.ticket(&tickets.items[0].human_key).unwrap();
assert_eq!(ticket_by_key.id, tickets.items[0].id);
let ticket = authority.ticket("00000000001J2").unwrap(); let ticket = authority.ticket("00000000001J2").unwrap();
assert!(ticket.body.contains("Ticket body")); assert!(ticket.body.contains("Ticket body"));
@@ -2999,6 +3123,18 @@ mod tests {
assert_eq!(objectives.items.len(), 1); assert_eq!(objectives.items.len(), 1);
assert_eq!(objectives.items[0].id, "00000000001J3"); assert_eq!(objectives.items[0].id, "00000000001J3");
assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]); assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]);
let objective_by_key = authority.objective(&objectives.items[0].human_key).unwrap();
assert_eq!(objective_by_key.id, objectives.items[0].id);
assert_eq!(
authority
.show_objective(
&objectives.items[0].human_key,
ObjectiveShowRequest::default(),
)
.unwrap()
.id,
objectives.items[0].id
);
let objective = authority.objective("00000000001J3").unwrap(); let objective = authority.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body")); assert!(objective.body.contains("Objective body"));
+96 -42
View File
@@ -246,6 +246,8 @@ pub struct WorkerCapabilitySummary {
pub struct WorkerSummary { pub struct WorkerSummary {
#[serde(flatten)] #[serde(flatten)]
pub worker: RuntimeWorkerRef, pub worker: RuntimeWorkerRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_key: Option<String>,
pub host_id: String, pub host_id: String,
/// Human-readable display name. This is not identity and may be duplicated. /// Human-readable display name. This is not identity and may be duplicated.
pub display_name: String, pub display_name: String,
@@ -340,6 +342,14 @@ pub struct WorkerTicketAssignmentRequest {
pub operation_id: String, pub operation_id: String,
} }
pub(crate) fn worker_spawn_create_fingerprint(
request: &WorkerSpawnRequest,
) -> Result<String, String> {
let encoded = serde_json::to_vec(request)
.map_err(|error| format!("serialize Worker create input: {error}"))?;
Ok(format!("sha256:{}", digest_hex(&encoded, 64)))
}
pub(crate) fn worker_spawn_idempotency( pub(crate) fn worker_spawn_idempotency(
request: &WorkerSpawnRequest, request: &WorkerSpawnRequest,
) -> Result<Option<(String, String)>, String> { ) -> Result<Option<(String, String)>, String> {
@@ -366,6 +376,12 @@ pub struct WorkerControlOperation {
pub input_fingerprint: String, pub input_fingerprint: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerCreateBinding {
pub worker_id: EmbeddedWorkerId,
pub create_fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkerSpawnRequest { pub struct WorkerSpawnRequest {
@@ -763,7 +779,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
} }
} }
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { fn spawn_worker(
&self,
_binding: WorkerCreateBinding,
request: WorkerSpawnRequest,
) -> WorkerSpawnResult {
WorkerSpawnResult { WorkerSpawnResult {
state: WorkerOperationState::Unsupported, state: WorkerOperationState::Unsupported,
worker: None, worker: None,
@@ -1226,6 +1246,7 @@ impl RuntimeRegistry {
pub fn spawn_worker( pub fn spawn_worker(
&self, &self,
runtime_id: &str, runtime_id: &str,
binding: WorkerCreateBinding,
request: WorkerSpawnRequest, request: WorkerSpawnRequest,
) -> Result<WorkerSpawnResult, RuntimeRegistryError> { ) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?; validate_backend_identifier("runtime_id", runtime_id)?;
@@ -1269,7 +1290,7 @@ impl RuntimeRegistry {
}); });
} }
} }
Ok(runtime.spawn_worker(request)) Ok(runtime.spawn_worker(binding, request))
} }
pub fn create_working_directory( pub fn create_working_directory(
@@ -1606,6 +1627,7 @@ impl EmbeddedWorkerRuntime {
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend( let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
FsRuntimeStoreOptions { FsRuntimeStoreOptions {
root: store_root.into(), root: store_root.into(),
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
display_name: Some("embedded".to_string()), display_name: Some("embedded".to_string()),
}, },
backend, backend,
@@ -1658,6 +1680,7 @@ impl EmbeddedWorkerRuntime {
); );
WorkerSummary { WorkerSummary {
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
human_key: None,
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
@@ -1697,6 +1720,7 @@ impl EmbeddedWorkerRuntime {
); );
WorkerSummary { WorkerSummary {
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
human_key: None,
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
@@ -1948,7 +1972,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} }
} }
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { fn spawn_worker(
&self,
binding: WorkerCreateBinding,
request: WorkerSpawnRequest,
) -> WorkerSpawnResult {
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
if request.resolved_working_directory_request.is_some() if request.resolved_working_directory_request.is_some()
|| request.resolved_working_directory.is_some() || request.resolved_working_directory.is_some()
@@ -1981,7 +2009,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
diagnostics.push(diagnostic( diagnostics.push(diagnostic(
"embedded_worker_name_display_only", "embedded_worker_name_display_only",
DiagnosticSeverity::Info, DiagnosticSeverity::Info,
"requested_worker_name is used only as display_name; embedded Runtime allocates opaque runtime-local worker ids".to_string(), "requested_worker_name is used only as display_name; Worker identity is allocated by Workspace authority".to_string(),
)); ));
} }
if matches!(request.acceptance, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } if expected_segments > 0) if matches!(request.acceptance, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } if expected_segments > 0)
@@ -2010,11 +2038,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}; };
} }
}; };
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
.expect("WorkerSpawnRequest serialization is infallible")
.map_or((None, None), |(key, fingerprint)| {
(Some(key), Some(fingerprint))
});
let workspace_api = match required_worker_workspace_api(&request) { let workspace_api = match required_worker_workspace_api(&request) {
Ok(workspace_api) => workspace_api, Ok(workspace_api) => workspace_api,
Err(diagnostic) => { Err(diagnostic) => {
@@ -2030,8 +2053,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
let workspace_id = workspace_api.workspace_id.clone(); let workspace_id = workspace_api.workspace_id.clone();
let config_bundle = spawn_config_bundle_ref(&request); let config_bundle = spawn_config_bundle_ref(&request);
let create_request = CreateWorkerRequest { let create_request = CreateWorkerRequest {
idempotency_key, worker_id: binding.worker_id,
idempotency_fingerprint, create_fingerprint: binding.create_fingerprint,
profile, profile,
display_name: request.requested_worker_name.clone(), display_name: request.requested_worker_name.clone(),
config_bundle, config_bundle,
@@ -2775,6 +2798,7 @@ impl RemoteWorkerRuntime {
); );
WorkerSummary { WorkerSummary {
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
human_key: None,
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
@@ -2818,6 +2842,7 @@ impl RemoteWorkerRuntime {
); );
WorkerSummary { WorkerSummary {
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
human_key: None,
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
@@ -3113,7 +3138,11 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { fn spawn_worker(
&self,
binding: WorkerCreateBinding,
request: WorkerSpawnRequest,
) -> WorkerSpawnResult {
if matches!( if matches!(
request.acceptance, request.acceptance,
WorkerSpawnAcceptanceRequirement::SocketReady WorkerSpawnAcceptanceRequirement::SocketReady
@@ -3152,11 +3181,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}; };
} }
}; };
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
.expect("WorkerSpawnRequest serialization is infallible")
.map_or((None, None), |(key, fingerprint)| {
(Some(key), Some(fingerprint))
});
let workspace_api = match required_worker_workspace_api(&request) { let workspace_api = match required_worker_workspace_api(&request) {
Ok(workspace_api) => workspace_api, Ok(workspace_api) => workspace_api,
Err(diagnostic) => { Err(diagnostic) => {
@@ -3170,8 +3194,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}; };
let config_bundle = spawn_config_bundle_ref(&request); let config_bundle = spawn_config_bundle_ref(&request);
let create = CreateWorkerRequest { let create = CreateWorkerRequest {
idempotency_key, worker_id: binding.worker_id,
idempotency_fingerprint, create_fingerprint: binding.create_fingerprint,
profile, profile,
display_name: request.requested_worker_name.clone(), display_name: request.requested_worker_name.clone(),
config_bundle, config_bundle,
@@ -4190,6 +4214,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
let host_id = host_id.into(); let host_id = host_id.into();
WorkerSummary { WorkerSummary {
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"), worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
human_key: None,
host_id, host_id,
display_name: "Worker runtime actions are not implemented".to_string(), display_name: "Worker runtime actions are not implemented".to_string(),
label: "Worker runtime actions are not implemented".to_string(), label: "Worker runtime actions are not implemented".to_string(),
@@ -4245,6 +4270,13 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
fn test_create_binding() -> WorkerCreateBinding {
WorkerCreateBinding {
worker_id: EmbeddedWorkerId::now_v7(),
create_fingerprint: "sha256:test-create".to_string(),
}
}
fn test_workspace_api() -> WorkspaceApiRef { fn test_workspace_api() -> WorkspaceApiRef {
WorkspaceApiRef { WorkspaceApiRef {
workspace_id: "workspace-test".to_string(), workspace_id: "workspace-test".to_string(),
@@ -4576,6 +4608,7 @@ mod tests {
host_id: host_id.to_string(), host_id: host_id.to_string(),
workers: vec![WorkerSummary { workers: vec![WorkerSummary {
worker: RuntimeWorkerRef::new(runtime_id, worker_id), worker: RuntimeWorkerRef::new(runtime_id, worker_id),
human_key: None,
host_id: host_id.to_string(), host_id: host_id.to_string(),
display_name: label.to_string(), display_name: label.to_string(),
label: label.to_string(), label: label.to_string(),
@@ -4899,11 +4932,16 @@ mod tests {
digest: bundle.metadata.digest.clone(), digest: bundle.metadata.digest.clone(),
}; };
request.resolved_config_bundle = Some(bundle); request.resolved_config_bundle = Some(bundle);
let binding = test_create_binding();
let result = registry let result = registry
.spawn_worker("embedded-worker-runtime", request) .spawn_worker("embedded-worker-runtime", binding.clone(), request)
.expect("spawn request"); .expect("spawn request");
assert_eq!(result.state, WorkerOperationState::Accepted); assert_eq!(result.state, WorkerOperationState::Accepted);
assert_eq!(
result.worker.as_ref().unwrap().worker.worker_id,
binding.worker_id.to_string()
);
let check = registry let check = registry
.check_config_bundle("embedded-worker-runtime", bundle_ref) .check_config_bundle("embedded-worker-runtime", bundle_ref)
.expect("bundle check"); .expect("bundle check");
@@ -4921,7 +4959,7 @@ mod tests {
let mut request = embedded_spawn_request(); let mut request = embedded_spawn_request();
request.resolved_workspace_api = None; request.resolved_workspace_api = None;
let spawned = runtime.spawn_worker(request); let spawned = runtime.spawn_worker(test_create_binding(), request);
assert_eq!(spawned.state, WorkerOperationState::Rejected); assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!( assert!(
@@ -4939,7 +4977,7 @@ mod tests {
Arc::new(FailingSpawnBackend), Arc::new(FailingSpawnBackend),
) )
.expect("test backend should connect"); .expect("test backend should connect");
let spawned = runtime.spawn_worker(embedded_spawn_request()); let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request());
assert_eq!(spawned.state, WorkerOperationState::Rejected); assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!(spawned.acceptance_evidence.is_empty()); assert!(spawned.acceptance_evidence.is_empty());
assert!(spawned.diagnostics.iter().any(|diagnostic| { assert!(spawned.diagnostics.iter().any(|diagnostic| {
@@ -5006,7 +5044,7 @@ mod tests {
Arc::new(AcceptingExecutionBackend::default()), Arc::new(AcceptingExecutionBackend::default()),
) )
.expect("test backend should connect"); .expect("test backend should connect");
let spawned = runtime.spawn_worker(embedded_spawn_request()); let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request());
assert_eq!(spawned.state, WorkerOperationState::Accepted); assert_eq!(spawned.state, WorkerOperationState::Accepted);
let worker = spawned.worker.expect("created embedded worker"); let worker = spawned.worker.expect("created embedded worker");
assert!(worker.capabilities.can_stop); assert!(worker.capabilities.can_stop);
@@ -5064,6 +5102,7 @@ mod tests {
let spawned = registry let spawned = registry
.spawn_worker( .spawn_worker(
EMBEDDED_RUNTIME_ID, EMBEDDED_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole { intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(), ticket_id: "00001KVZSGT0Q".to_string(),
@@ -5162,6 +5201,7 @@ mod tests {
let spawned = registry let spawned = registry
.spawn_worker( .spawn_worker(
EMBEDDED_RUNTIME_ID, EMBEDDED_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole { intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(), ticket_id: "00001KVZSGT0Q".to_string(),
@@ -5204,6 +5244,7 @@ mod tests {
let result = registry let result = registry
.spawn_worker( .spawn_worker(
EMBEDDED_RUNTIME_ID, EMBEDDED_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCompanion, intent: WorkerSpawnIntent::WorkspaceCompanion,
requested_worker_name: None, requested_worker_name: None,
@@ -5251,7 +5292,8 @@ mod tests {
#[test] #[test]
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() { fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
let worker_json = worker_json("remote:primary", "1"); let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
let worker_json = worker_json("remote:primary", &worker_id);
let (base_url, server) = serve_mock_http(vec![ let (base_url, server) = serve_mock_http(vec![
mock_response( mock_response(
"GET", "GET",
@@ -5262,19 +5304,19 @@ mod tests {
), ),
mock_response( mock_response(
"GET", "GET",
"/v1/workers/1", format!("/v1/workers/{worker_id}"),
true, true,
200, 200,
json!({ "worker": worker_json.clone() }).to_string(), json!({ "worker": worker_json.clone() }).to_string(),
), ),
mock_response( mock_response(
"POST", "POST",
"/v1/workers/1/input", format!("/v1/workers/{worker_id}/input"),
true, true,
200, 200,
json!({ json!({
"ack": { "ack": {
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 }, "worker_ref": { "runtime_id": "remote:primary", "worker_id": worker_id.clone() },
"status": "running" "status": "running"
} }
}) })
@@ -5298,20 +5340,24 @@ mod tests {
); );
let observation = registry let observation = registry
.observation_source(&RuntimeWorkerRef::new("remote:primary", "1")) .observation_source(&RuntimeWorkerRef::new("remote:primary", &worker_id))
.expect("remote runtime exposes backend-owned WS observation source"); .expect("remote runtime exposes backend-owned WS observation source");
let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation let crate::observation::RuntimeObservationSource::RemoteWs(observation) = observation
else { else {
panic!("remote runtime should expose a remote WS observation source"); panic!("remote runtime should expose a remote WS observation source");
}; };
assert!(observation.endpoint.starts_with("ws://127.0.0.1:")); assert!(observation.endpoint.starts_with("ws://127.0.0.1:"));
assert!(observation.endpoint.ends_with("/v1/workers/1/protocol/ws")); assert!(
observation
.endpoint
.ends_with(&format!("/v1/workers/{worker_id}/protocol/ws"))
);
assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str())); assert_eq!(observation.bearer_token.as_deref(), Some(secret.as_str()));
let workers = registry.list_workers(10); let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 1); assert_eq!(workers.items.len(), 1);
assert_eq!(workers.items[0].worker.runtime_id, "remote:primary"); assert_eq!(workers.items[0].worker.runtime_id, "remote:primary");
assert_eq!(workers.items[0].worker.worker_id, "1"); assert_eq!(workers.items[0].worker.worker_id, worker_id.as_str());
assert_eq!( assert_eq!(
workers.items[0].implementation.kind, workers.items[0].implementation.kind,
"remote_worker_runtime" "remote_worker_runtime"
@@ -5324,7 +5370,7 @@ mod tests {
let input = registry let input = registry
.send_input( .send_input(
&RuntimeWorkerRef::new("remote:primary", "1"), &RuntimeWorkerRef::new("remote:primary", &worker_id),
WorkerInputRequest { WorkerInputRequest {
kind: WorkerInputKind::User, kind: WorkerInputKind::User,
content: "hello remote".to_string(), content: "hello remote".to_string(),
@@ -5350,6 +5396,10 @@ mod tests {
#[test] #[test]
fn remote_runtime_projection_uses_canonical_worker_status_for_stop_capability() { fn remote_runtime_projection_uses_canonical_worker_status_for_stop_capability() {
let worker_ids = (1..=4)
.map(|value| EmbeddedWorkerId::from_legacy_u64(value).to_string())
.collect::<Vec<_>>();
let worker_id = worker_ids[0].clone();
let (base_url, server) = serve_mock_http(vec![ let (base_url, server) = serve_mock_http(vec![
mock_response( mock_response(
"GET", "GET",
@@ -5358,21 +5408,26 @@ mod tests {
200, 200,
json!({ json!({
"workers": [ "workers": [
worker_json_with_status("remote:primary", "1", "stopped"), worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
worker_json_with_status("remote:primary", "2", "cancelled"), worker_json_with_status("remote:primary", &worker_ids[1], "cancelled"),
worker_json_with_status("remote:primary", "3", "paused"), worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
worker_json_with_status("remote:primary", "4", "idle") worker_json_with_status("remote:primary", &worker_ids[3], "idle")
] ]
}) })
.to_string(), .to_string(),
), ),
mock_response( mock_response(
"GET", "GET",
"/v1/workers/1", format!("/v1/workers/{worker_id}"),
true, true,
200, 200,
json!({ json!({
"worker": worker_json_with_status("remote:primary", "1", "stopped")}) "worker": worker_json_with_status(
"remote:primary",
&worker_ids[0],
"stopped"
)
})
.to_string(), .to_string(),
), ),
]); ]);
@@ -5402,7 +5457,7 @@ mod tests {
assert_eq!(workers.items[3].state, "idle"); assert_eq!(workers.items[3].state, "idle");
let stopped_detail = registry let stopped_detail = registry
.worker(&RuntimeWorkerRef::new("remote:primary", "1")) .worker(&RuntimeWorkerRef::new("remote:primary", &worker_id))
.unwrap(); .unwrap();
assert!(!stopped_detail.capabilities.can_stop); assert!(!stopped_detail.capabilities.can_stop);
assert_eq!(stopped_detail.state, "stopped"); assert_eq!(stopped_detail.state, "stopped");
@@ -5595,7 +5650,7 @@ mod tests {
#[derive(Clone)] #[derive(Clone)]
struct MockResponse { struct MockResponse {
method: &'static str, method: &'static str,
path: &'static str, path: String,
require_auth: bool, require_auth: bool,
status: u16, status: u16,
body: String, body: String,
@@ -5603,14 +5658,14 @@ mod tests {
fn mock_response( fn mock_response(
method: &'static str, method: &'static str,
path: &'static str, path: impl Into<String>,
require_auth: bool, require_auth: bool,
status: u16, status: u16,
body: String, body: String,
) -> MockResponse { ) -> MockResponse {
MockResponse { MockResponse {
method, method,
path, path: path.into(),
require_auth, require_auth,
status, status,
body, body,
@@ -5667,7 +5722,6 @@ mod tests {
worker_id: &str, worker_id: &str,
status: &str, status: &str,
) -> serde_json::Value { ) -> serde_json::Value {
let worker_id = worker_id.parse::<u64>().unwrap();
json!({ json!({
"worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id }, "worker_ref": { "runtime_id": runtime_id, "worker_id": worker_id },
"runtime_id": runtime_id, "runtime_id": runtime_id,
+60 -3
View File
@@ -23,6 +23,7 @@ enum Command {
ConfigDiff(WorkspacePathOptions), ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>), Identity(Vec<String>),
TrustRuntime(Vec<String>), TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
Skills(SkillsCommand), Skills(SkillsCommand),
Help, Help,
} }
@@ -85,6 +86,17 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::ConfigDiff(options) => run_config_diff(options), Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args), Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
let database = database.unwrap_or_else(ServerConfig::default_server_database_path);
let plan = SqliteWorkspaceStore::migration_plan(&database).map_err(|error| {
CliError(format!(
"migration dry-run failed for {}: {error}",
database.display()
))
})?;
println!("{}", serde_json::to_string_pretty(&plan)?);
Ok(())
}
Command::Skills(command) => run_skills(command), Command::Skills(command) => run_skills(command),
Command::Help => Ok(()), Command::Help => Ok(()),
} }
@@ -107,6 +119,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
"config" => parse_config_command(rest), "config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())), "identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
"skills" => parse_skills_command(rest), "skills" => parse_skills_command(rest),
"serve" => { "serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") { if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -120,7 +133,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `skills`, or `serve`" "unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
@@ -718,6 +731,32 @@ fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
} }
} }
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
let mut dry_run = false;
let mut database = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--dry-run" => dry_run = true,
"--database" => {
index += 1;
database =
Some(PathBuf::from(args.get(index).ok_or_else(|| {
CliError("--database requires a path".to_string())
})?));
}
value => {
return Err(CliError(format!("unknown migrate option: {value}")));
}
}
index += 1;
}
if !dry_run {
return Err(CliError("migrate currently requires --dry-run".to_string()));
}
Ok(Command::MigrateDryRun { database })
}
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> { fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
print_skills_help(); print_skills_help();
@@ -875,7 +914,8 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
@@ -899,7 +939,8 @@ fn print_skills_help() {
fn print_serve_help() { fn print_serve_help() {
println!( println!(
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help" "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
); );
} }
@@ -939,6 +980,22 @@ mod tests {
assert_eq!(name, "debug-rust"); assert_eq!(name, "debug-rust");
} }
#[test]
fn parse_migrate_requires_dry_run_and_accepts_database_path() {
let error = parse_migrate_command(&[]).unwrap_err();
assert_eq!(error.to_string(), "migrate currently requires --dry-run");
let command = parse_migrate_command(&[
"--dry-run".to_string(),
"--database".to_string(),
"/tmp/server.db".to_string(),
])
.unwrap();
let Command::MigrateDryRun { database } = command else {
panic!("expected migration dry-run command");
};
assert_eq!(database, Some(PathBuf::from("/tmp/server.db")));
}
#[test] #[test]
fn parse_serve_accepts_listen_only() { fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
+18
View File
@@ -31,6 +31,7 @@ pub struct InvalidProjectRecord {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketSummary { pub struct TicketSummary {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub priority: String, pub priority: String,
@@ -66,6 +67,7 @@ pub struct TicketListResponse {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketDetail { pub struct TicketDetail {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub readiness: Option<String>, pub readiness: Option<String>,
@@ -121,6 +123,8 @@ pub struct TicketRelation {
pub ticket_id: String, pub ticket_id: String,
pub kind: String, pub kind: String,
pub target: String, pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_human_key: Option<String>,
pub note: Option<String>, pub note: Option<String>,
pub author: String, pub author: String,
pub at: String, pub at: String,
@@ -130,6 +134,8 @@ pub struct TicketRelation {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct DerivedTicketRelation { pub struct DerivedTicketRelation {
pub source_ticket: String, pub source_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_human_key: Option<String>,
pub inverse_kind: String, pub inverse_kind: String,
pub forward_kind: String, pub forward_kind: String,
pub note: Option<String>, pub note: Option<String>,
@@ -141,6 +147,8 @@ pub struct DerivedTicketRelation {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelationBlocker { pub struct TicketRelationBlocker {
pub blocking_ticket: String, pub blocking_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking_human_key: Option<String>,
pub reason_kind: String, pub reason_kind: String,
pub relation_kind: String, pub relation_kind: String,
pub note: Option<String>, pub note: Option<String>,
@@ -174,6 +182,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
ticket_id: relation.ticket_id, ticket_id: relation.ticket_id,
kind: relation.kind.as_str().to_string(), kind: relation.kind.as_str().to_string(),
target: relation.target, target: relation.target,
target_human_key: None,
note: relation.note, note: relation.note,
author: relation.author, author: relation.author,
at: relation.at, at: relation.at,
@@ -184,6 +193,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
.into_iter() .into_iter()
.map(|relation| DerivedTicketRelation { .map(|relation| DerivedTicketRelation {
source_ticket: relation.source_ticket, source_ticket: relation.source_ticket,
source_human_key: None,
inverse_kind: relation.inverse_kind, inverse_kind: relation.inverse_kind,
forward_kind: relation.forward_kind.as_str().to_string(), forward_kind: relation.forward_kind.as_str().to_string(),
note: relation.note, note: relation.note,
@@ -196,6 +206,7 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
.into_iter() .into_iter()
.map(|blocker| TicketRelationBlocker { .map(|blocker| TicketRelationBlocker {
blocking_ticket: blocker.blocking_ticket, blocking_ticket: blocker.blocking_ticket,
blocking_human_key: None,
reason_kind: blocker.reason_kind, reason_kind: blocker.reason_kind,
relation_kind: blocker.relation_kind.as_str().to_string(), relation_kind: blocker.relation_kind.as_str().to_string(),
note: blocker.note, note: blocker.note,
@@ -231,6 +242,7 @@ pub struct QueryPage {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ObjectiveLinkSummary { pub struct ObjectiveLinkSummary {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
} }
@@ -252,6 +264,8 @@ pub struct TicketAssignmentSummary {
pub assignment_id: String, pub assignment_id: String,
pub runtime_id: String, pub runtime_id: String,
pub worker_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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -313,6 +327,7 @@ pub struct TicketQueryRequest {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryItem { pub struct TicketQueryItem {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub readiness: Option<String>, pub readiness: Option<String>,
@@ -399,6 +414,7 @@ pub struct ObjectiveEventDetail {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveLinkedTicketSummary { pub struct ObjectiveLinkedTicketSummary {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
} }
@@ -406,6 +422,7 @@ pub struct ObjectiveLinkedTicketSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary { pub struct ObjectiveSummary {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub created_at: Option<String>, pub created_at: Option<String>,
@@ -418,6 +435,7 @@ pub struct ObjectiveSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveDetail { pub struct ObjectiveDetail {
pub id: String, pub id: String,
pub human_key: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub revision: String, pub revision: String,
+99 -38
View File
@@ -166,6 +166,25 @@ pub enum WorkerRetentionError {
Invalid(String), Invalid(String),
} }
pub(crate) fn repair_worker_diagnostics_archive_table(conn: &Connection) -> crate::Result<bool> {
let existed: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_diagnostics_archives')",
[],
|row| row.get(0),
)?;
if !existed {
conn.execute_batch(
"CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);",
)?;
}
Ok(!existed)
}
pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> { pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> {
conn.execute_batch(r#" conn.execute_batch(r#"
CREATE TABLE workspace_worker_retention_policy_revisions ( CREATE TABLE workspace_worker_retention_policy_revisions (
@@ -285,7 +304,7 @@ impl SqliteWorkspaceStore {
let worker=match load_worker(&tx,&req.workspace_id,&req.worker)? { let worker=match load_worker(&tx,&req.workspace_id,&req.worker)? {
Some(v)=>v, Some(v)=>v,
None=>{ None=>{
let other:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry WHERE runtime_id=?1 AND runtime_worker_id=?2 AND workspace_id!=?3)",params![req.worker.runtime_id,req.worker.worker_id,req.workspace_id],|r|r.get(0))?; let other:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry WHERE runtime_id=?1 AND worker_id=?2 AND workspace_id!=?3)",params![req.worker.runtime_id,req.worker.worker_id,req.workspace_id],|r|r.get(0))?;
return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()})); return Err(StoreError::InvalidInput(if other{"cross-workspace".into()}else{"worker-missing".into()}));
} }
}; };
@@ -366,11 +385,13 @@ impl SqliteWorkspaceStore {
plan_id: plan.plan_id.clone(), plan_id: plan.plan_id.clone(),
reason: "Worker disappeared after execution fence".to_string(), reason: "Worker disappeared after execution fence".to_string(),
})?; })?;
let worker_number = plan.worker.worker_id.parse::<u64>().map_err(|_| { let worker_id = plan
WorkerRetentionError::Invalid( .worker
"Runtime Worker id is not a canonical unsigned integer".to_string(), .worker_id
) .parse::<worker_runtime::identity::WorkerId>()
})?; .map_err(|_| {
WorkerRetentionError::Invalid("Worker id must be a canonical UUIDv7".to_string())
})?;
let removed_at = plan.created_at.clone(); let removed_at = plan.created_at.clone();
let prior_failure_category = plan.failure_category.clone(); let prior_failure_category = plan.failure_category.clone();
Ok(PreparedWorkerRemoval { Ok(PreparedWorkerRemoval {
@@ -380,7 +401,7 @@ impl SqliteWorkspaceStore {
archive_id: plan.archive_id.clone(), archive_id: plan.archive_id.clone(),
workspace_id: plan.workspace_id.clone(), workspace_id: plan.workspace_id.clone(),
source_runtime_id: plan.worker.runtime_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_runtime::identity::WorkerId::new(worker_number), worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(), expected_worker_revision: plan.worker_revision.clone(),
expected_run_generation: plan.run_generation, expected_run_generation: plan.run_generation,
source_created_at: worker.created_at, source_created_at: worker.created_at,
@@ -435,11 +456,13 @@ impl SqliteWorkspaceStore {
return Ok(None); return Ok(None);
}; };
let prior_failure_category = plan.failure_category.clone(); let prior_failure_category = plan.failure_category.clone();
let worker_number = plan.worker.worker_id.parse::<u64>().map_err(|_| { let worker_id = plan
WorkerRetentionError::Invalid( .worker
"Runtime Worker id is not a canonical unsigned integer".to_string(), .worker_id
) .parse::<worker_runtime::identity::WorkerId>()
})?; .map_err(|_| {
WorkerRetentionError::Invalid("Worker id must be a canonical UUIDv7".to_string())
})?;
let worker = if plan.state == WorkerRemovalPlanState::Succeeded { let worker = if plan.state == WorkerRemovalPlanState::Succeeded {
None None
} else { } else {
@@ -455,7 +478,7 @@ impl SqliteWorkspaceStore {
archive_id: plan.archive_id.clone(), archive_id: plan.archive_id.clone(),
workspace_id: plan.workspace_id.clone(), workspace_id: plan.workspace_id.clone(),
source_runtime_id: plan.worker.runtime_id.clone(), source_runtime_id: plan.worker.runtime_id.clone(),
worker_id: worker_runtime::identity::WorkerId::new(worker_number), worker_id: worker_id,
expected_worker_revision: plan.worker_revision.clone(), expected_worker_revision: plan.worker_revision.clone(),
expected_run_generation: plan.run_generation, expected_run_generation: plan.run_generation,
source_created_at: worker source_created_at: worker
@@ -544,7 +567,7 @@ impl SqliteWorkspaceStore {
if plan.metadata_disposition==MetadataDisposition::Tombstone{ if plan.metadata_disposition==MetadataDisposition::Tombstone{
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?; tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
} }
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND runtime_worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?; let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));} if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
tx.execute("UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL,updated_at=?1 WHERE operation_id=?2",params![now,operation_id])?; tx.execute("UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL,updated_at=?1 WHERE operation_id=?2",params![now,operation_id])?;
tx.execute("INSERT OR IGNORE INTO worker_retention_audit_events(event_id,operation_id,workspace_id,event_kind,detail,created_at) VALUES(?1,?2,?3,'worker_removed',?4,?5)",params![stable("wre",operation_id),operation_id,workspace_id,format!("runtime_id={} worker_id={} session={} metadata={} diagnostics={}",plan.worker.runtime_id,plan.worker.worker_id,sess(plan.session_disposition),meta(plan.metadata_disposition),diag(plan.diagnostics_disposition)),now])?; tx.execute("INSERT OR IGNORE INTO worker_retention_audit_events(event_id,operation_id,workspace_id,event_kind,detail,created_at) VALUES(?1,?2,?3,'worker_removed',?4,?5)",params![stable("wre",operation_id),operation_id,workspace_id,format!("runtime_id={} worker_id={} session={} metadata={} diagnostics={}",plan.worker.runtime_id,plan.worker.worker_id,sess(plan.session_disposition),meta(plan.metadata_disposition),diag(plan.diagnostics_disposition)),now])?;
@@ -593,7 +616,7 @@ impl SqliteWorkspaceStore {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let policy_configured = load_policy(&tx, workspace_id)?.is_some(); let policy_configured = load_policy(&tx, workspace_id)?.is_some();
let mut statement = tx.prepare( let mut statement = tx.prepare(
"SELECT CAST(runtime_worker_id AS TEXT), retention_state "SELECT CAST(worker_id AS TEXT), retention_state
FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2", FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2",
)?; )?;
let registry = statement let registry = statement
@@ -718,7 +741,7 @@ struct WorkerRow {
updated_at: String, updated_at: String,
} }
fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<Option<WorkerRow>> { fn load_worker(c: &Connection, w: &str, r: &RuntimeWorkerRef) -> crate::Result<Option<WorkerRow>> {
c.query_row("SELECT display_name,profile,retention_state,created_at,updated_at FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND runtime_worker_id=?3",params![w,r.runtime_id,r.worker_id],|x|Ok(WorkerRow{display_name:x.get(0)?,profile:x.get(1)?,retention_state:x.get(2)?,created_at:x.get(3)?,updated_at:x.get(4)?})).optional().map_err(StoreError::from) c.query_row("SELECT display_name,profile,retention_state,created_at,updated_at FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![w,r.runtime_id,r.worker_id],|x|Ok(WorkerRow{display_name:x.get(0)?,profile:x.get(1)?,retention_state:x.get(2)?,created_at:x.get(3)?,updated_at:x.get(4)?})).optional().map_err(StoreError::from)
} }
fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> { fn load_policy(c: &Connection, w: &str) -> crate::Result<Option<WorkerRetentionPolicy>> {
c.query_row( c.query_row(
@@ -1042,16 +1065,33 @@ mod tests {
use super::*; use super::*;
use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord}; use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord};
use worker_runtime::identity::WorkerId; use worker_runtime::identity::WorkerId;
fn worker_id() -> WorkerId {
WorkerId::from_legacy_u64(1)
}
fn setup() -> SqliteWorkspaceStore { fn setup() -> SqliteWorkspaceStore {
let s = SqliteWorkspaceStore::in_memory().unwrap(); let s = SqliteWorkspaceStore::in_memory().unwrap();
s.with_conn(|c|{c.execute("INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at)VALUES('w','W','active','t','t')",[])?;c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,runtime_worker_id,display_name,profile,retention_state,created_at,updated_at)VALUES('w','r',1,'one','builtin:coder','normal','created','rev1')",[])?;Ok(())}).unwrap(); s.with_conn(|c| {
c.execute(
"INSERT INTO workspaces(workspace_id,display_name,state,created_at,updated_at) \
VALUES('w','W','active','t','t')",
[],
)?;
c.execute(
"INSERT INTO worker_registry(\
workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
[worker_id().to_string()],
)?;
Ok(())
})
.unwrap();
s s
} }
fn inv() -> WorkerRetentionInventory { fn inv() -> WorkerRetentionInventory {
WorkerRetentionInventory { WorkerRetentionInventory {
workspace_id: "w".into(), workspace_id: "w".into(),
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
run_generation: 2, run_generation: 2,
session_id: Some("s".into()), session_id: Some("s".into()),
segment_ids: vec!["a".into()], segment_ids: vec!["a".into()],
@@ -1064,7 +1104,7 @@ mod tests {
workspace_id: "w".into(), workspace_id: "w".into(),
worker: RuntimeWorkerRef { worker: RuntimeWorkerRef {
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: "1".into(), worker_id: worker_id().to_string(),
}, },
expected_worker_revision: "rev1".into(), expected_worker_revision: "rev1".into(),
reason: "cleanup".into(), reason: "cleanup".into(),
@@ -1213,7 +1253,10 @@ mod tests {
SessionDisposition::Archive SessionDisposition::Archive
); );
assert_eq!(prepared.runtime_request.policy_revision, 1); assert_eq!(prepared.runtime_request.policy_revision, 1);
assert_eq!(prepared.runtime_request.worker_id, WorkerId::new(1)); assert_eq!(
prepared.runtime_request.worker_id,
WorkerId::from_legacy_u64(1)
);
let retry = s let retry = s
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap(); .unwrap();
@@ -1234,7 +1277,7 @@ mod tests {
operation_id: p.operation_id.clone(), operation_id: p.operation_id.clone(),
input_fingerprint: p.input_fingerprint.clone(), input_fingerprint: p.input_fingerprint.clone(),
expected_worker_revision: p.worker_revision.clone(), expected_worker_revision: p.worker_revision.clone(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
session_disposition: p.session_disposition, session_disposition: p.session_disposition,
diagnostics_disposition: p.diagnostics_disposition, diagnostics_disposition: p.diagnostics_disposition,
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest { archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
@@ -1242,7 +1285,7 @@ mod tests {
archive_id: p.archive_id.clone().unwrap(), archive_id: p.archive_id.clone().unwrap(),
workspace_id: "w".into(), workspace_id: "w".into(),
source_runtime_id: "r".into(), source_runtime_id: "r".into(),
source_worker_id: WorkerId::new(1), source_worker_id: worker_id(),
source_session_id: "s".into(), source_session_id: "s".into(),
segment_ids: vec!["a".into()], segment_ids: vec!["a".into()],
source_created_at: "created".into(), source_created_at: "created".into(),
@@ -1284,7 +1327,23 @@ mod tests {
#[test] #[test]
fn assignment_and_orphan_are_authoritative() { fn assignment_and_orphan_are_authoritative() {
let s = setup(); let s = setup();
s.with_conn(|c|{c.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at)VALUES('w','ticket','assignment','r','1','test','t')",[])?;c.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at)VALUES('w','ticket','assignment','r','1','t')",[])?;Ok(())}).unwrap(); s.with_conn(|c| {
let stable_worker_id = worker_id().to_string();
c.execute(
"INSERT INTO ticket_worker_assignments(\
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at\
) VALUES('w','ticket','assignment','r',?1,'test','t')",
[&stable_worker_id],
)?;
c.execute(
"INSERT INTO ticket_current_worker_assignments(\
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at\
) VALUES('w','ticket','assignment','r',?1,'t')",
[&stable_worker_id],
)?;
Ok(())
})
.unwrap();
let p = s.plan_worker_removal(&req(), &inv()).unwrap(); let p = s.plan_worker_removal(&req(), &inv()).unwrap();
assert!( assert!(
matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket") matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
@@ -1292,7 +1351,7 @@ mod tests {
let runtime_only = WorkerRetentionInventory { let runtime_only = WorkerRetentionInventory {
workspace_id: "w".into(), workspace_id: "w".into(),
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: WorkerId::new(2), worker_id: WorkerId::from_legacy_u64(2),
run_generation: 1, run_generation: 1,
session_id: Some("orphan-session".into()), session_id: Some("orphan-session".into()),
segment_ids: vec![], segment_ids: vec![],
@@ -1304,10 +1363,12 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(diagnostics.len(), 2); assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.iter().any(|item| { assert!(diagnostics.iter().any(|item| {
item.worker_id == "2" && item.category == "runtime_aggregate_without_backend_registry" item.worker_id == WorkerId::from_legacy_u64(2).to_string()
&& item.category == "runtime_aggregate_without_backend_registry"
})); }));
assert!(diagnostics.iter().any(|item| { assert!(diagnostics.iter().any(|item| {
item.worker_id == "1" && item.category == "backend_registry_without_runtime_aggregate" item.worker_id == worker_id().to_string()
&& item.category == "backend_registry_without_runtime_aggregate"
})); }));
let count: i64 = s let count: i64 = s
.with_conn(|conn| { .with_conn(|conn| {
@@ -1375,7 +1436,7 @@ mod tests {
operation_id: p.operation_id.clone(), operation_id: p.operation_id.clone(),
input_fingerprint: p.input_fingerprint.clone(), input_fingerprint: p.input_fingerprint.clone(),
expected_worker_revision: p.worker_revision.clone(), expected_worker_revision: p.worker_revision.clone(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
session_disposition: SessionDisposition::Purge, session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Purge, diagnostics_disposition: DiagnosticsDisposition::Purge,
archive: None, archive: None,
@@ -1394,7 +1455,7 @@ mod tests {
operation_id: plan.operation_id.clone(), operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(), input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(), expected_worker_revision: plan.worker_revision.clone(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
session_disposition: plan.session_disposition, session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition, diagnostics_disposition: plan.diagnostics_disposition,
archive: None, archive: None,
@@ -1408,15 +1469,15 @@ mod tests {
store store
.begin_worker_removal("w", &plan.plan_id, &plan.input_fingerprint) .begin_worker_removal("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap(); .unwrap();
result.worker_id = WorkerId::new(2); result.worker_id = WorkerId::from_legacy_u64(2);
assert!( assert!(
store store
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result) .commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
.is_err() .is_err()
); );
let count: i64 = store.with_conn(|conn| conn.query_row( let count: i64 = store.with_conn(|conn| conn.query_row(
"SELECT COUNT(*) FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND runtime_worker_id=1", "SELECT COUNT(*) FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[], [worker_id().to_string()],
|row| row.get(0), |row| row.get(0),
).map_err(StoreError::from)).unwrap(); ).map_err(StoreError::from)).unwrap();
assert_eq!(count, 1); assert_eq!(count, 1);
@@ -1433,7 +1494,7 @@ mod tests {
workspace_id: "w".into(), workspace_id: "w".into(),
worker: RuntimeWorkerRef { worker: RuntimeWorkerRef {
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: "1".into(), worker_id: worker_id().to_string(),
}, },
display_name: "stale".into(), display_name: "stale".into(),
profile: None, profile: None,
@@ -1447,8 +1508,8 @@ mod tests {
}; };
store.upsert_worker_registry(&stale).unwrap(); store.upsert_worker_registry(&stale).unwrap();
let revision: String = store.with_conn(|conn| conn.query_row( let revision: String = store.with_conn(|conn| conn.query_row(
"SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND runtime_worker_id=1", "SELECT updated_at FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[], [worker_id().to_string()],
|row| row.get(0), |row| row.get(0),
).map_err(StoreError::from)).unwrap(); ).map_err(StoreError::from)).unwrap();
assert_eq!(revision, "rev1"); assert_eq!(revision, "rev1");
@@ -1459,7 +1520,7 @@ mod tests {
assignment_id: "new-assignment".into(), assignment_id: "new-assignment".into(),
worker: RuntimeWorkerRef { worker: RuntimeWorkerRef {
runtime_id: "r".into(), runtime_id: "r".into(),
worker_id: "1".into(), worker_id: worker_id().to_string(),
}, },
assigned_by: "test".into(), assigned_by: "test".into(),
assigned_at: "t".into(), assigned_at: "t".into(),
@@ -1488,7 +1549,7 @@ mod tests {
operation_id: prepared.plan.operation_id.clone(), operation_id: prepared.plan.operation_id.clone(),
input_fingerprint: prepared.plan.input_fingerprint.clone(), input_fingerprint: prepared.plan.input_fingerprint.clone(),
expected_worker_revision: prepared.plan.worker_revision.clone(), expected_worker_revision: prepared.plan.worker_revision.clone(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
session_disposition: prepared.plan.session_disposition, session_disposition: prepared.plan.session_disposition,
diagnostics_disposition: prepared.plan.diagnostics_disposition, diagnostics_disposition: prepared.plan.diagnostics_disposition,
archive: None, archive: None,
@@ -1522,7 +1583,7 @@ mod tests {
operation_id: prepared.plan.operation_id.clone(), operation_id: prepared.plan.operation_id.clone(),
input_fingerprint: prepared.plan.input_fingerprint.clone(), input_fingerprint: prepared.plan.input_fingerprint.clone(),
expected_worker_revision: prepared.plan.worker_revision.clone(), expected_worker_revision: prepared.plan.worker_revision.clone(),
worker_id: WorkerId::new(1), worker_id: worker_id(),
session_disposition: prepared.plan.session_disposition, session_disposition: prepared.plan.session_disposition,
diagnostics_disposition: prepared.plan.diagnostics_disposition, diagnostics_disposition: prepared.plan.diagnostics_disposition,
archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest { archive: Some(worker_runtime::retention::WorkerSessionArchiveManifest {
@@ -1530,7 +1591,7 @@ mod tests {
archive_id: prepared.plan.archive_id.clone().unwrap(), archive_id: prepared.plan.archive_id.clone().unwrap(),
workspace_id: "w".into(), workspace_id: "w".into(),
source_runtime_id: "r".into(), source_runtime_id: "r".into(),
source_worker_id: WorkerId::new(1), source_worker_id: worker_id(),
source_session_id: "s".into(), source_session_id: "s".into(),
segment_ids: vec!["a".into()], segment_ids: vec!["a".into()],
source_created_at: "created".into(), source_created_at: "created".into(),
@@ -8,6 +8,7 @@ use worker_runtime::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
}; };
use worker_runtime::identity::WorkerId;
use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary}; use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary};
#[derive(Debug)] #[derive(Debug)]
@@ -57,8 +58,8 @@ const TOKEN: &str = "runtime-subscription-test-token";
fn create_request(name: &str) -> CreateWorkerRequest { fn create_request(name: &str) -> CreateWorkerRequest {
CreateWorkerRequest { CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::Builtin("builtin:companion".to_string()),
display_name: Some(name.to_string()), display_name: Some(name.to_string()),
config_bundle: None, config_bundle: None,
+216 -28
View File
@@ -76,11 +76,12 @@ use crate::hosts::{
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult, RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult,
RuntimeSummary, TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest, RuntimeSummary, TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest,
WorkerCompletionsResult, WorkerControlOperation, WorkerImplementationSummary, WorkerInputKind, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult, WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult,
WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
WorkerWorkspaceSummary, worker_spawn_create_fingerprint,
}; };
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_backend::execute_memory_backend_operation_with_authority;
@@ -108,7 +109,7 @@ use crate::store::{
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord,
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind,
}; };
use crate::{Error, Result}; use crate::{Error, Result};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
@@ -120,7 +121,7 @@ use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundlesResponse,
RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
}; };
use worker_runtime::identity::RuntimeWorkerRef; use worker_runtime::identity::{RuntimeWorkerRef, WorkerId};
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
@@ -887,7 +888,40 @@ impl WorkspaceApi {
&now_registry_timestamp(), &now_registry_timestamp(),
)?; )?;
} }
let result = match self.runtime.spawn_worker(runtime_id, request) { let create_fingerprint = worker_spawn_create_fingerprint(&request)
.map_err(|message| Error::Config(message.to_string()))?;
let allocation_key = request
.resolved_control_operation
.as_ref()
.map(|operation| operation.operation_id.clone())
.or_else(|| {
request
.ticket_assignment
.as_ref()
.map(|assignment| assignment.operation_id.clone())
})
.unwrap_or_else(|| format!("manual:{}", WorkerId::now_v7()));
let worker_id = self
.config_store
.reserve_worker_create(
&self.config.workspace_id,
runtime_id,
&allocation_key,
&create_fingerprint,
)
.map_err(|error| Error::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "workspace_worker_allocation_conflict".to_string(),
message: error.to_string(),
})?;
let create_binding = WorkerCreateBinding {
worker_id,
create_fingerprint,
};
let result = match self
.runtime
.spawn_worker(runtime_id, create_binding, request)
{
Ok(result) => result, Ok(result) => result,
Err(error) => { Err(error) => {
if let Some((workdir_id, reservation_id)) = attachment_reservation.as_ref() { if let Some((workdir_id, reservation_id)) = attachment_reservation.as_ref() {
@@ -911,6 +945,24 @@ impl WorkspaceApi {
return Ok(result); return Ok(result);
}; };
let worker_ref = worker.worker.clone(); let worker_ref = worker.worker.clone();
if worker_ref.worker_id != worker_id.to_string() {
if let Some((workdir_id, reservation_id)) = attachment_reservation.as_ref() {
let _ = self.store.release_worker_workdir_attachment_reservation(
&self.config.workspace_id,
workdir_id,
reservation_id,
);
}
return Err(Error::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "workspace_worker_identity_mismatch".to_string(),
message: format!(
"Runtime returned Worker {} for reserved Workspace Worker {}",
worker_ref.worker_id, worker_id
),
}
.into());
}
let replacement = match self let replacement = match self
.runtime .runtime
.replace_worker_workspace_api(&worker_ref, workspace_api) .replace_worker_workspace_api(&worker_ref, workspace_api)
@@ -1017,6 +1069,9 @@ impl WorkspaceApi {
return Err(error); return Err(error);
} }
} }
self.config_store
.complete_worker_create_reservation(&self.config.workspace_id, worker_id)
.map_err(|error| Error::Config(error.to_string()))?;
Ok(result) Ok(result)
} }
@@ -1583,6 +1638,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/workers", "/api/w/{workspace_id}/workers",
get(scoped_list_workers).post(scoped_create_workspace_worker), get(scoped_list_workers).post(scoped_create_workspace_worker),
) )
.route(
"/api/w/{workspace_id}/workers/{worker_ref}",
get(scoped_get_workspace_worker),
)
.route( .route(
"/api/w/{workspace_id}/protocol/ws", "/api/w/{workspace_id}/protocol/ws",
get(scoped_workspace_protocol_ws), get(scoped_workspace_protocol_ws),
@@ -2409,6 +2468,12 @@ struct ScopedConfigBundlePath {
bundle_id: String, bundle_id: String,
} }
#[derive(Debug, Deserialize)]
struct ScopedWorkspaceWorkerReferencePath {
workspace_id: String,
worker_ref: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ScopedRuntimeWorkerPath { struct ScopedRuntimeWorkerPath {
workspace_id: String, workspace_id: String,
@@ -4010,11 +4075,22 @@ fn require_completed_target_observation(
Err(Error::InvalidInput("target selector moved outside completion evidence".into()).into()) Err(Error::InvalidInput("target selector moved outside completion evidence".into()).into())
} }
fn resolve_workspace_ticket_reference(
api: &WorkspaceApi,
workspace_id: &str,
reference: &str,
) -> ApiResult<String> {
api.store
.resolve_resource_reference(workspace_id, WorkspaceResourceKind::Ticket, reference)?
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
}
async fn scoped_show_merge_request( async fn scoped_show_merge_request(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let store = merge_request_store(&api, &workspace_id)?; let store = merge_request_store(&api, &workspace_id)?;
let mut mr = store.get(&workspace_id, &ticket_id)?; let mut mr = store.get(&workspace_id, &ticket_id)?;
mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?; mr.thread = store.thread_page(&workspace_id, &ticket_id, None, 100)?;
@@ -4049,6 +4125,7 @@ async fn scoped_merge_request_readiness(
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<merge_request::ReadinessReport>> { ) -> ApiResult<Json<merge_request::ReadinessReport>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let store = merge_request_store(&api, &workspace_id)?; let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?; let mr = store.get(&workspace_id, &ticket_id)?;
let current_subject_ref = mr.selector_from.as_deref().and_then(|selector| { let current_subject_ref = mr.selector_from.as_deref().and_then(|selector| {
@@ -4077,6 +4154,7 @@ async fn scoped_open_merge_request(
Json(input): Json<OpenMergeRequestRequest>, Json(input): Json<OpenMergeRequestRequest>,
) -> ApiResult<Json<merge_request::MergeRequest>> { ) -> ApiResult<Json<merge_request::MergeRequest>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
let assignment = api let assignment = api
@@ -4139,6 +4217,7 @@ async fn scoped_merge_request_thread(
Query(query): Query<MergeRequestThreadQuery>, Query(query): Query<MergeRequestThreadQuery>,
) -> ApiResult<Json<Vec<merge_request::MergeRequestThreadEvent>>> { ) -> ApiResult<Json<Vec<merge_request::MergeRequestThreadEvent>>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
Ok(Json( Ok(Json(
merge_request_store(&api, &workspace_id)?.thread_page( merge_request_store(&api, &workspace_id)?.thread_page(
&workspace_id, &workspace_id,
@@ -4156,6 +4235,7 @@ async fn scoped_repair_merge_request_selector(
Json(input): Json<RepairMergeRequestSelectorRequest>, Json(input): Json<RepairMergeRequestSelectorRequest>,
) -> ApiResult<Json<merge_request::MergeRequest>> { ) -> ApiResult<Json<merge_request::MergeRequest>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
reject_non_browser_reopen_auth(&headers)?; reject_non_browser_reopen_auth(&headers)?;
let _actor = require_actor(&api, &headers).await?; let _actor = require_actor(&api, &headers).await?;
@@ -4214,6 +4294,7 @@ async fn scoped_register_merge_request_review_capability(
Json(input): Json<RegisterMergeRequestReviewCapabilityRequest>, Json(input): Json<RegisterMergeRequestReviewCapabilityRequest>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
let assignment = api let assignment = api
@@ -4264,6 +4345,7 @@ async fn scoped_submit_merge_request_review(
Json(input): Json<SubmitMergeRequestReviewRequest>, Json(input): Json<SubmitMergeRequestReviewRequest>,
) -> ApiResult<Json<merge_request::ReviewEvent>> { ) -> ApiResult<Json<merge_request::ReviewEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
let store = merge_request_store(&api, &workspace_id)?; let store = merge_request_store(&api, &workspace_id)?;
let mr = store.get(&workspace_id, &ticket_id)?; let mr = store.get(&workspace_id, &ticket_id)?;
let selector = mr let selector = mr
@@ -4295,6 +4377,7 @@ async fn scoped_revoke_merge_request_review(
Json(input): Json<RevokeMergeRequestReviewRequest>, Json(input): Json<RevokeMergeRequestReviewRequest>,
) -> ApiResult<Json<merge_request::ReviewRevokedEvent>> { ) -> ApiResult<Json<merge_request::ReviewRevokedEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
if !input.explicit_confirmation { if !input.explicit_confirmation {
return Err(Error::BrowserReopenConfirmationRequired.into()); return Err(Error::BrowserReopenConfirmationRequired.into());
@@ -4340,6 +4423,7 @@ async fn scoped_complete_merge_request(
Json(input): Json<CompleteMergeRequestRequest>, Json(input): Json<CompleteMergeRequestRequest>,
) -> ApiResult<Json<merge_request::MergeEvent>> { ) -> ApiResult<Json<merge_request::MergeEvent>> {
let workspace_id = parse_workspace_id(&workspace_id)?; let workspace_id = parse_workspace_id(&workspace_id)?;
let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?;
require_workspace_access(&workspace_id, &api)?; require_workspace_access(&workspace_id, &api)?;
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
require_online_workspace_orchestrator_source(&api, &source)?; require_online_workspace_orchestrator_source(&api, &source)?;
@@ -6180,6 +6264,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( async fn scoped_list_workers(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -9456,11 +9569,36 @@ struct WorkerShowProjection {
updated_at: String, 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( async fn get_runtime_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<WorkerShowProjection>> { ) -> 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 let worker = api
.runtime .runtime
.worker(&worker_ref) .worker(&worker_ref)
@@ -9473,17 +9611,20 @@ async fn get_runtime_worker(
.store .store
.list_workdir_registry(&api.config.workspace_id, 500)?; .list_workdir_registry(&api.config.workspace_id, 500)?;
let updated_at = record.updated_at.clone(); let updated_at = record.updated_at.clone();
Ok(Json(WorkerShowProjection { let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
worker: merge_worker_registry_projection(Some(&worker), &record, links, &workdirs), worker.human_key = api.store.resource_human_key(
updated_at, &api.config.workspace_id,
})) WorkspaceResourceKind::Worker,
&worker_ref.worker_id,
)?;
Ok(Json(WorkerShowProjection { worker, updated_at }))
} }
async fn restore_runtime_worker( async fn restore_runtime_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<WorkerRestoreResponse>> { ) -> 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)?; let mut result = api.restore_workspace_worker(&worker)?;
if let Some(worker) = result.worker.as_ref() { if let Some(worker) = result.worker.as_ref() {
let record = sync_worker_observation(&api, worker)?; let record = sync_worker_observation(&api, worker)?;
@@ -10090,7 +10231,7 @@ async fn send_runtime_worker_input(
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerInputRequest>, Json(request): Json<WorkerInputRequest>,
) -> ApiResult<Json<WorkerInputResult>> { ) -> 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 let result = api
.runtime .runtime
.send_input(&worker, request) .send_input(&worker, request)
@@ -10103,7 +10244,7 @@ async fn runtime_worker_completions(
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerCompletionsRequest>, Json(request): Json<WorkerCompletionsRequest>,
) -> ApiResult<Json<WorkerCompletionsResult>> { ) -> 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 let result = api
.runtime .runtime
.worker_completions(&worker, request) .worker_completions(&worker, request)
@@ -10116,7 +10257,7 @@ async fn stop_runtime_worker(
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerLifecycleRequest>, Json(request): Json<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> { ) -> 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 let result = api
.runtime .runtime
.stop_worker(&worker, request) .stop_worker(&worker, request)
@@ -10139,7 +10280,7 @@ async fn cancel_runtime_worker(
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
Json(request): Json<WorkerLifecycleRequest>, Json(request): Json<WorkerLifecycleRequest>,
) -> ApiResult<Json<WorkerLifecycleResult>> { ) -> 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 let result = api
.runtime .runtime
.cancel_worker(&worker, request) .cancel_worker(&worker, request)
@@ -10576,12 +10717,18 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
let links = api let links = api
.store .store
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; .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), observed.get(&record.worker),
&record, &record,
links, links,
&workdir_records, &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 { Ok(RuntimeListResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
@@ -11449,6 +11596,7 @@ fn record_worker_summary(
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary { fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
WorkerSummary { WorkerSummary {
worker: record.worker.clone(), worker: record.worker.clone(),
human_key: None,
host_id: "backend-registry".to_string(), host_id: "backend-registry".to_string(),
display_name: record.display_name.clone(), display_name: record.display_name.clone(),
label: record.display_name.clone(), label: record.display_name.clone(),
@@ -11889,11 +12037,11 @@ fn working_directory_request_for_browser(
}) })
} }
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> { fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<WorkerId> {
worker_id.parse::<u64>().map_err(|_| { worker_id.parse::<WorkerId>().map_err(|_| {
settings_bad_request( settings_bad_request(
"workspace_worker_id_invalid", "workspace_worker_id_invalid",
"Runtime Worker id must be an unsigned integer", "Workspace Worker id must be a UUIDv7",
) )
}) })
} }
@@ -12420,6 +12568,13 @@ mod tests {
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord,
}; };
fn test_create_binding() -> WorkerCreateBinding {
WorkerCreateBinding {
worker_id: WorkerId::now_v7(),
create_fingerprint: "sha256:test-create".to_string(),
}
}
#[test] #[test]
fn reopen_confirmation_rejects_api_token_actor_before_session_resolution() { fn reopen_confirmation_rejects_api_token_actor_before_session_resolution() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -14084,6 +14239,7 @@ mod tests {
.runtime .runtime
.spawn_worker( .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID, EMBEDDED_WORKER_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
requested_worker_name: Some(MEMORY_CONSOLIDATION_PROFILE.to_string()), requested_worker_name: Some(MEMORY_CONSOLIDATION_PROFILE.to_string()),
intent: WorkerSpawnIntent::WorkspaceOrchestrator, intent: WorkerSpawnIntent::WorkspaceOrchestrator,
@@ -14313,6 +14469,7 @@ mod tests {
.runtime .runtime
.spawn_worker( .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID, EMBEDDED_WORKER_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
requested_worker_name: Some("notification-source".to_string()), requested_worker_name: Some("notification-source".to_string()),
intent: WorkerSpawnIntent::TicketRole { intent: WorkerSpawnIntent::TicketRole {
@@ -14533,13 +14690,21 @@ mod tests {
}; };
let source_worker = api let source_worker = api
.runtime .runtime
.spawn_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn("source-worker")) .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID,
test_create_binding(),
spawn("source-worker"),
)
.unwrap() .unwrap()
.worker .worker
.unwrap(); .unwrap();
let recipient_worker = api let recipient_worker = api
.runtime .runtime
.spawn_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn("recipient-worker")) .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID,
test_create_binding(),
spawn("recipient-worker"),
)
.unwrap() .unwrap()
.worker .worker
.unwrap(); .unwrap();
@@ -14727,6 +14892,7 @@ mod tests {
.runtime .runtime
.spawn_worker( .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID, EMBEDDED_WORKER_RUNTIME_ID,
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
requested_worker_name: Some("orchestrator-source".to_string()), requested_worker_name: Some("orchestrator-source".to_string()),
intent: WorkerSpawnIntent::TicketRole { intent: WorkerSpawnIntent::TicketRole {
@@ -15037,9 +15203,25 @@ mod tests {
TEST_CREATED_AT, TEST_CREATED_AT,
) )
.unwrap(); .unwrap();
let reserved_worker_id = api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
EMBEDDED_WORKER_RUNTIME_ID,
"pending-spawn-operation",
&pending_fingerprint,
)
.unwrap();
let spawned_before_backend_failure = api let spawned_before_backend_failure = api
.runtime .runtime
.spawn_worker(EMBEDDED_WORKER_RUNTIME_ID, pending_request.clone()) .spawn_worker(
EMBEDDED_WORKER_RUNTIME_ID,
WorkerCreateBinding {
worker_id: reserved_worker_id,
create_fingerprint: pending_fingerprint.clone(),
},
pending_request.clone(),
)
.unwrap() .unwrap()
.worker .worker
.unwrap(); .unwrap();
@@ -15223,7 +15405,12 @@ mod tests {
.unwrap() .unwrap()
.create(ticket::NewTicket::new("Browser Ticket API")) .create(ticket::NewTicket::new("Browser Ticket API"))
.unwrap(); .unwrap();
let ticket_human_key = ticket_ref.human_key.clone().unwrap();
let ticket_id = ticket_ref.id; let ticket_id = ticket_ref.id;
assert_eq!(
resolve_workspace_ticket_reference(&api, TEST_WORKSPACE_ID, &ticket_human_key).unwrap(),
ticket_id
);
let path = || ScopedRecordPath { let path = || ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(), id: ticket_id.clone(),
@@ -16632,8 +16819,8 @@ mod tests {
fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest { fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest {
let bundle = runtime_test_bundle(); let bundle = runtime_test_bundle();
worker_runtime::catalog::CreateWorkerRequest { worker_runtime::catalog::CreateWorkerRequest {
idempotency_key: None, worker_id: WorkerId::now_v7(),
idempotency_fingerprint: None, create_fingerprint: "test-create".to_string(),
profile: worker_runtime::catalog::ProfileSelector::Builtin( profile: worker_runtime::catalog::ProfileSelector::Builtin(
"builtin:companion".to_string(), "builtin:companion".to_string(),
), ),
@@ -17847,6 +18034,7 @@ mod tests {
.runtime .runtime
.spawn_worker( .spawn_worker(
"embedded-worker-runtime", "embedded-worker-runtime",
test_create_binding(),
WorkerSpawnRequest { WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole { intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(), ticket_id: "00001KVZSGT0Q".to_string(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
# SubWorker spawn failed because the inherited profile required an unavailable control service
Date: 2026-08-19
## Observed behavior
While splitting a migration investigation into read-only Runtime and Server analysis, both `SubWorkerSpawn` calls failed before the child session started:
```text
install Internal Worker features: Worker feature installation failed:
builtin:worker-observation: required service requirement is not available:
builtin:worker.control
```
The requested children used `builtin:coder` with read-only scopes. No child was created and no delegated work ran.
## Impact
A parent Worker with the SubWorker tools available cannot necessarily spawn a catalog profile whose transitive features require `builtin:worker.control`. The failure occurs at profile feature installation rather than being rejected when the profile is selected or omitted from the available SubWorker profile choices. The parent must continue the investigation without context splitting.
## Expected behavior
The SubWorker spawn layer should either install the parent-owned `worker.control` service before resolving dependent child features, provide a SubWorker-compatible profile projection that does not require unavailable Workspace Worker control, or reject the profile choice up front with an actionable capability diagnostic. A read-only delegated scope must remain read-only; satisfying the service dependency must not widen filesystem or Workspace authority.
+1 -1
View File
@@ -178,4 +178,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client. * Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded. * Service-private Internal Workers are deliberately excluded.
*/ */
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
+13 -1
View File
@@ -5,6 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string };
export type TicketSummary = { export type TicketSummary = {
id: string; id: string;
human_key: string;
title: string; title: string;
state: string; state: string;
priority: string; priority: string;
@@ -51,7 +52,12 @@ export type TicketEventDetail = {
references: Array<string>; references: Array<string>;
}; };
export type ObjectiveLinkSummary = { id: string; title: string; state: string }; export type ObjectiveLinkSummary = {
id: string;
human_key: string;
title: string;
state: string;
};
export type TicketEvidenceEvent = { export type TicketEvidenceEvent = {
event_ref: string; event_ref: string;
@@ -66,6 +72,7 @@ export type TicketAssignmentSummary = {
assignment_id: string; assignment_id: string;
runtime_id: string; runtime_id: string;
worker_id: string; worker_id: string;
worker_human_key?: string | null;
}; };
export type TicketMergeRequestSummary = { export type TicketMergeRequestSummary = {
@@ -115,6 +122,7 @@ export type TicketQueryRequest = {
export type TicketQueryItem = { export type TicketQueryItem = {
id: string; id: string;
human_key: string;
title: string; title: string;
state: string; state: string;
readiness: string | null; readiness: string | null;
@@ -150,6 +158,7 @@ export type TicketRelation = {
ticket_id: string; ticket_id: string;
kind: string; kind: string;
target: string; target: string;
target_human_key?: string | null;
note: string | null; note: string | null;
author: string; author: string;
at: string; at: string;
@@ -157,6 +166,7 @@ export type TicketRelation = {
export type DerivedTicketRelation = { export type DerivedTicketRelation = {
source_ticket: string; source_ticket: string;
source_human_key?: string | null;
inverse_kind: string; inverse_kind: string;
forward_kind: string; forward_kind: string;
note: string | null; note: string | null;
@@ -166,6 +176,7 @@ export type DerivedTicketRelation = {
export type TicketRelationBlocker = { export type TicketRelationBlocker = {
blocking_ticket: string; blocking_ticket: string;
blocking_human_key?: string | null;
reason_kind: string; reason_kind: string;
relation_kind: string; relation_kind: string;
note: string | null; note: string | null;
@@ -187,6 +198,7 @@ export type TicketRelationView = {
export type TicketDetail = { export type TicketDetail = {
id: string; id: string;
human_key: string;
title: string; title: string;
state: string; state: string;
readiness: string | null; readiness: string | null;
@@ -1287,7 +1287,10 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => {
}]); }]);
assertEquals(projection.lines, []); assertEquals(projection.lines, []);
assertEquals(projection.internalWorkers.length, 1); assertEquals(projection.internalWorkers.length, 1);
assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output"); assertEquals(
projection.internalWorkers[0].console.lines[0].body,
"child output",
);
projection = projector.append([{ projection = projector.append([{
eventId: "2", eventId: "2",
@@ -1314,9 +1317,36 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
kind: "sub_worker", kind: "sub_worker",
}, },
revision: 4, revision: 4,
entries: [], entries: [{
kind: "assistant_item",
ts: 1,
item: {
kind: "tool_call",
call_id: "committed-call",
name: "Read",
arguments: JSON.stringify({ file_path: "/repo/a.md" }),
},
}, {
kind: "tool_result",
ts: 2,
item: {
kind: "tool_result",
call_id: "committed-call",
summary: "read file",
content: "content",
is_error: false,
},
}],
status: "idle", status: "idle",
in_flight: { blocks: [] }, in_flight: {
blocks: [{
kind: "tool_call",
id: "committed-call",
name: "Read",
args: JSON.stringify({ file_path: "/repo/a.md" }),
state: "done",
}],
},
internal_workers: [], internal_workers: [],
}]; }];
const projector = createConsoleProjector(); const projector = createConsoleProjector();
@@ -1337,9 +1367,110 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
}, },
}]); }]);
const projection = projector.append([{ eventId: "snapshot", event }]); const projection = projector.append([{ eventId: "snapshot", event }]);
assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [ assertEquals(
"replacement", projection.internalWorkers.map((worker) => worker.worker.session_id),
]); [
"replacement",
],
);
const childLines = projection.internalWorkers[0].console.lines;
assertEquals(childLines.length, 1);
assertEquals(new Set(childLines.map((line) => line.id)).size, 1);
assertEquals(childLines[0].kind, "tool");
});
Deno.test("terminal Internal Worker removal drops descendants and fences late events", () => {
const worker = {
session_id: "child-session",
name: "child",
parent_session_id: "parent-session",
kind: "sub_worker" as const,
};
const nestedWorker = {
session_id: "grandchild-session",
name: "grandchild",
parent_session_id: "child-session",
kind: "sub_worker" as const,
};
const projector = createConsoleProjector();
let projection = projector.append([{
eventId: "child",
event: {
event: "internal_worker",
data: {
worker,
revision: 2,
event: {
event: "internal_worker",
data: {
worker: nestedWorker,
revision: 1,
event: { event: "text_done", data: { text: "nested" } },
},
},
},
},
}]);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(
projection.internalWorkers[0].console.internalWorkers.length,
1,
);
projection = projector.append([{
eventId: "removed",
event: {
event: "internal_worker_removed",
data: { worker, revision: 3 },
},
}, {
eventId: "late",
event: {
event: "internal_worker",
data: {
worker,
revision: 4,
event: { event: "text_done", data: { text: "must stay removed" } },
},
},
}]);
assertEquals(projection.internalWorkers, []);
const snapshot = snapshotEvent("/repo");
projection = projector.append([{ eventId: "snapshot", event: snapshot }]);
assertEquals(projection.internalWorkers, []);
assertEquals(projection.removedInternalWorkers, {});
});
Deno.test("stale Internal Worker removal cannot discard a newer projection", () => {
const worker = {
session_id: "child-session",
name: "child",
parent_session_id: "parent-session",
kind: "sub_worker" as const,
};
const projector = createConsoleProjector();
projector.append([{
eventId: "current",
event: {
event: "internal_worker",
data: {
worker,
revision: 4,
event: { event: "text_done", data: { text: "current" } },
},
},
}]);
const projection = projector.append([{
eventId: "stale-removal",
event: {
event: "internal_worker_removed",
data: { worker, revision: 3 },
},
}]);
assertEquals(projection.internalWorkers.length, 1);
assertEquals(projection.internalWorkers[0].revision, 4);
}); });
Deno.test("snapshot restores TaskStore state from system history", () => { Deno.test("snapshot restores TaskStore state from system history", () => {
@@ -94,6 +94,8 @@ export type ConsoleProjection = {
cwd: string | null; cwd: string | null;
lastEventId: string | null; lastEventId: string | null;
internalWorkers: InternalWorkerProjection[]; internalWorkers: InternalWorkerProjection[];
/** Terminal child-session fences, reset only by an authoritative snapshot. */
removedInternalWorkers: Record<string, number>;
}; };
export type ConsoleTimelineLineSelection = { export type ConsoleTimelineLineSelection = {
@@ -179,6 +181,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
cwd: null, cwd: null,
lastEventId: null, lastEventId: null,
internalWorkers: [], internalWorkers: [],
removedInternalWorkers: {},
}; };
} }
@@ -224,6 +227,21 @@ function projectVisibleConsole(
}; };
} }
function appendSnapshotInFlightLines(
projection: ConsoleProjection,
blocks: InFlightBlock[],
eventId: string,
cwd: string | null,
): void {
const lineIds = new Set(projection.lines.map((line) => line.id));
blocks.forEach((block, index) => {
const pending = inFlightLine(`${eventId}:${index}`, block, cwd);
if (lineIds.has(pending.id)) return;
projection.lines.push(pending);
lineIds.add(pending.id);
});
}
function projectInternalWorkerSnapshot( function projectInternalWorkerSnapshot(
snapshot: InternalWorkerSnapshot, snapshot: InternalWorkerSnapshot,
eventId: string, eventId: string,
@@ -235,15 +253,12 @@ function projectInternalWorkerSnapshot(
cwd, cwd,
); );
console.status = snapshot.status; console.status = snapshot.status;
for (const block of snapshot.in_flight?.blocks ?? []) { appendSnapshotInFlightLines(
console.lines.push( console,
inFlightLine( snapshot.in_flight?.blocks ?? [],
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`, `${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
block, cwd,
cwd, );
),
);
}
if (snapshot.error) { if (snapshot.error) {
console.lines.push({ console.lines.push({
id: `${eventId}:internal:${snapshot.worker.session_id}:error`, id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
@@ -274,6 +289,7 @@ export function applyProtocolEvent(
cwd: projection.cwd, cwd: projection.cwd,
lastEventId: envelope.eventId, lastEventId: envelope.eventId,
internalWorkers: [...projection.internalWorkers], internalWorkers: [...projection.internalWorkers],
removedInternalWorkers: { ...projection.removedInternalWorkers },
}; };
const event = envelope.event; const event = envelope.event;
@@ -385,15 +401,25 @@ export function applyProtocolEvent(
next.lines = snapshot.lines; next.lines = snapshot.lines;
next.tasks = snapshot.tasks; next.tasks = snapshot.tasks;
next.taskNextId = snapshot.taskNextId; next.taskNextId = snapshot.taskNextId;
for (const block of event.data.in_flight?.blocks ?? []) { appendSnapshotInFlightLines(
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd)); next,
} event.data.in_flight?.blocks ?? [],
`${envelope.eventId}:snapshot-in-flight`,
next.cwd,
);
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) => next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd) projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
); );
next.removedInternalWorkers = {};
break; break;
} }
case "internal_worker": { case "internal_worker": {
if (
Object.hasOwn(
next.removedInternalWorkers,
event.data.worker.session_id,
)
) break;
const existingIndex = next.internalWorkers.findIndex((worker) => const existingIndex = next.internalWorkers.findIndex((worker) =>
worker.worker.session_id === event.data.worker.session_id worker.worker.session_id === event.data.worker.session_id
); );
@@ -418,6 +444,19 @@ export function applyProtocolEvent(
else next.internalWorkers.push(updated); else next.internalWorkers.push(updated);
break; break;
} }
case "internal_worker_removed": {
const existingIndex = next.internalWorkers.findIndex((worker) =>
worker.worker.session_id === event.data.worker.session_id
);
const existingRevision = existingIndex >= 0
? next.internalWorkers[existingIndex].revision
: 0;
if (event.data.revision <= existingRevision) break;
next.removedInternalWorkers[event.data.worker.session_id] =
event.data.revision;
if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1);
break;
}
case "status": case "status":
next.status = event.data.status; next.status = event.data.status;
break; break;
@@ -1279,6 +1318,7 @@ function snapshotProjectionFromEntries(
cwd, cwd,
lastEventId: eventId, lastEventId: eventId,
internalWorkers: [], internalWorkers: [],
removedInternalWorkers: {},
}; };
entries.forEach((entry, index) => entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry) applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
@@ -0,0 +1,49 @@
const HUMAN_KEY_PATTERN = /^(T|O|W)-(\d+)/;
export function resourceHumanKey(reference: string): string {
const match = HUMAN_KEY_PATTERN.exec(reference);
return match ? `${match[1]}-${match[2]}` : reference;
}
export function slugifyResourceTitle(title: string): string {
const slug = title
.normalize("NFKD")
.replace(/\p{Mark}+/gu, "")
.toLocaleLowerCase("en-US")
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80)
.replace(/-+$/g, "");
return slug || "resource";
}
export function canonicalResourceReference(
humanKey: string,
title: string,
): string {
return `${humanKey}-${slugifyResourceTitle(title)}`;
}
export function ticketHref(
workspaceId: string,
ticket: { human_key: string; title: string },
): string {
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.human_key, ticket.title))}`;
}
export function objectiveHref(
workspaceId: string,
objective: { human_key: string; title: string },
): string {
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.human_key, objective.title))}`;
}
export function workerHref(
workspaceId: string,
worker: { human_key?: string; display_name: string; worker_id: string },
): string {
const reference = worker.human_key
? canonicalResourceReference(worker.human_key, worker.display_name)
: worker.worker_id;
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
}
@@ -76,6 +76,7 @@ export type WorkerCapabilities = {
export type Worker = { export type Worker = {
runtime_id: string; runtime_id: string;
worker_id: string; worker_id: string;
human_key?: string;
host_id: string; host_id: string;
display_name: string; display_name: string;
label: string; label: string;
@@ -119,7 +120,6 @@ export type WorkingDirectoryRepositoryOption = {
export type WorkingDirectoryOccupancy = { export type WorkingDirectoryOccupancy = {
runtime_id: string; runtime_id: string;
runtime_worker_id: number;
worker_id: string; worker_id: string;
display_name: string; display_name: string;
linked_at: string; linked_at: string;
@@ -135,7 +135,7 @@ export type WorkingDirectorySummary = {
materializer_kind: string; materializer_kind: string;
status: string; status: string;
cleanliness?: string | null; cleanliness?: string | null;
primary_worker_id?: number | null; primary_worker_id?: string | null;
occupied_by?: WorkingDirectoryOccupancy | null; occupied_by?: WorkingDirectoryOccupancy | null;
cleanup_target: { cleanup_target: {
kind: string; kind: string;
@@ -404,6 +404,7 @@ export type {
export type ObjectiveSummary = { export type ObjectiveSummary = {
id: string; id: string;
human_key: string;
title: string; title: string;
state: string; state: string;
updated_at?: string | null; updated_at?: string | null;
@@ -412,13 +413,22 @@ export type ObjectiveSummary = {
record_source?: string; record_source?: string;
}; };
export type ObjectiveLinkedTicketSummary = {
id: string;
human_key: string;
title: string;
state: string;
};
export type ObjectiveDetail = { export type ObjectiveDetail = {
id: string; id: string;
human_key: string;
title: string; title: string;
state: string; state: string;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
linked_tickets: string[]; linked_tickets: string[];
linked_ticket_summaries: ObjectiveLinkedTicketSummary[];
body: string; body: string;
body_truncated: boolean; body_truncated: boolean;
record_source: string; record_source: string;
@@ -120,8 +120,7 @@ Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
...options.working_directories[0], ...options.working_directories[0],
occupied_by: { occupied_by: {
runtime_id: "embedded", runtime_id: "embedded",
runtime_worker_id: 12, worker_id: "0198f82e-6d90-7f15-a121-174a02e10e77",
worker_id: "embedded:12",
display_name: "Worker 12", display_name: "Worker 12",
linked_at: "2026-07-24T00:00:00Z", linked_at: "2026-07-24T00:00:00Z",
}, },
@@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"]; export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick< export type TicketCardSummary = Pick<
TicketSummary, TicketSummary,
"id" | "title" | "state" | "priority" | "updated_at" "id" | "human_key" | "title" | "state" | "priority" | "updated_at"
>; >;
const STATE_SORT_ORDER = new Map<string, number>([ const STATE_SORT_ORDER = new Map<string, number>([
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http'; import { formatDate } from '$lib/workspace/api/http';
import { objectiveHref } from '$lib/workspace/resource-links';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -18,7 +19,7 @@
{:else} {:else}
<div class="objective-list"> <div class="objective-list">
{#each data.objectives.items as objective (objective.id)} {#each data.objectives.items as objective (objective.id)}
<a class="objective-row" href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}> <a class="objective-row" href={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main"> <div class="objective-main">
<div class="objective-title-row"> <div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong> <strong class="objective-title">{objective.title}</strong>
@@ -29,7 +30,7 @@
<div class="objective-meta" aria-label="Objective metadata"> <div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span> <span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span> <span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<code>{objective.id}</code> <code>{objective.human_key}</code>
</div> </div>
</a> </a>
{/each} {/each}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http'; import { formatDate } from '$lib/workspace/api/http';
import { objectiveHref, ticketHref } from '$lib/workspace/resource-links';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -14,7 +15,7 @@
{#if data.objectives} {#if data.objectives}
<div class="objective-list compact"> <div class="objective-list compact">
{#each data.objectives.items as objective (objective.id)} {#each data.objectives.items as objective (objective.id)}
<a class="objective-row" class:active={objective.id === data.objectiveId} href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}> <a class="objective-row" class:active={objective.id === data.objectiveId} href={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main"> <div class="objective-main">
<div class="objective-title-row"> <div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong> <strong class="objective-title">{objective.title}</strong>
@@ -24,7 +25,7 @@
</div> </div>
<div class="objective-meta" aria-label="Objective metadata"> <div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span> <span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<code>{objective.id}</code> <code>{objective.human_key}</code>
</div> </div>
</a> </a>
{/each} {/each}
@@ -60,7 +61,15 @@
</div> </div>
<div> <div>
<dt>Linked tickets</dt> <dt>Linked tickets</dt>
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd> <dd>
{#if data.objective.linked_ticket_summaries.length}
{#each data.objective.linked_ticket_summaries as ticket, index}
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.human_key}</a>
{/each}
{:else}
none
{/if}
</dd>
</div> </div>
</dl> </dl>
<pre class="objective-body">{data.objective.body}</pre> <pre class="objective-body">{data.objective.body}</pre>
@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type { import type {
ObjectiveDetail, ObjectiveDetail,
ObjectiveListResponse, ObjectiveListResponse,
@@ -7,7 +12,7 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = params.objectiveId; const objectiveId = resourceHumanKey(params.objectiveId);
const [objectives, objective] = await Promise.all([ const [objectives, objective] = await Promise.all([
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")), loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>( loadJson<ObjectiveDetail>(
@@ -16,6 +21,19 @@ export const load: PageLoad = async ({ fetch, params }) => {
), ),
]); ]);
if (objective.data) {
const canonical = canonicalResourceReference(
objective.data.human_key,
objective.data.title,
);
if (params.objectiveId !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${encodeURIComponent(canonical)}`,
);
}
}
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
objectiveId, objectiveId,
@@ -146,7 +146,7 @@
<td> <td>
{#if workdir.occupied_by} {#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span> <span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.runtime_worker_id}</small> <small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.worker_id}</small>
{:else} {:else}
<span class="muted"></span> <span class="muted"></span>
{/if} {/if}
@@ -7,6 +7,7 @@
TicketListResponse, TicketListResponse,
TicketSummary, TicketSummary,
} from "$lib/generated/ticket-api"; } from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import { import {
ticketLanes, ticketLanes,
type WorkspaceOrchestratorStatus, type WorkspaceOrchestratorStatus,
@@ -176,9 +177,9 @@
{#each lane.tickets as ticket (ticket.id)} {#each lane.tickets as ticket (ticket.id)}
<a <a
class="ticket-card" class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`} href={ticketHref(data.workspaceId, ticket)}
> >
<span class="ticket-card-id">{ticket.id}</span> <span class="ticket-card-id">{ticket.human_key}</span>
<strong>{ticket.title}</strong> <strong>{ticket.title}</strong>
<div class="ticket-card-meta"> <div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span> <span>{ticket.state} · {ticket.priority}</span>
@@ -320,8 +320,8 @@
{#if ticket.relations.blockers.length > 0} {#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list"> <div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker} {#each ticket.relations.blockers as blocker}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}> <a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_human_key ?? blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_ticket}</strong> <strong>Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket}</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span> <span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a> </a>
{/each} {/each}
@@ -329,16 +329,16 @@
{/if} {/if}
<div class="ticket-relations-list"> <div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation} {#each ticket.relations.outgoing as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}> <a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_human_key ?? relation.target)}`}>
<span>{relationLabel(relation.kind)}</span> <span>{relationLabel(relation.kind)}</span>
<strong>{relation.target}</strong> <strong>{relation.target_human_key ?? relation.target}</strong>
{#if relation.note}<small>{relation.note}</small>{/if} {#if relation.note}<small>{relation.note}</small>{/if}
</a> </a>
{/each} {/each}
{#each ticket.relations.incoming as relation} {#each ticket.relations.incoming as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}> <a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_human_key ?? relation.source_ticket)}`}>
<span>{relationLabel(relation.inverse_kind)}</span> <span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_ticket}</strong> <strong>{relation.source_human_key ?? relation.source_ticket}</strong>
{#if relation.note}<small>{relation.note}</small>{/if} {#if relation.note}<small>{relation.note}</small>{/if}
</a> </a>
{/each} {/each}
@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types"; import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
@@ -15,12 +20,19 @@ async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise
} }
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`); const reference = resourceHumanKey(params.ticketId);
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([ const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath), loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")), loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")), loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`), loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
]); ]);
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest }; if (ticket.data) {
const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title);
if (params.ticketId !== canonical) {
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
}
}
return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest };
}) satisfies PageLoad; }) satisfies PageLoad;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store'; import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerHref } from '$lib/workspace/resource-links';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers'; import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types'; import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
@@ -195,11 +195,11 @@
<tr> <tr>
<td> <td>
{#if canOpenWorkerConsole(worker)} {#if canOpenWorkerConsole(worker)}
<a class="worker-title-link" href={workerConsoleHref(worker, data.workspaceId)}><strong>{workerDisplayName}</strong></a> <a class="worker-title-link" href={workerHref(data.workspaceId, worker)}><strong>{workerDisplayName}</strong></a>
{:else} {:else}
<strong>{workerDisplayName}</strong> <strong>{workerDisplayName}</strong>
{/if} {/if}
<small>worker <code>{worker.worker_id}</code></small> <small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
</td> </td>
<td><code>{worker.runtime_id}</code></td> <td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td> <td>{workerProfile(worker)}</td>
@@ -0,0 +1,32 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{data.worker?.human_key ?? 'Worker'} · Yoi</title></svelte:head>
<section class="workspace-page-shell">
{#if data.workerError}
<p class="error-message">{data.workerError}</p>
{:else if data.worker}
<header class="workspace-page-header">
<div>
<p class="eyebrow">{data.worker.human_key}</p>
<h1>{data.worker.display_name}</h1>
</div>
<a
class="button-primary"
href={workspaceRoute(
data.workspaceId,
`/runtimes/${data.worker.runtime_id}/workers/${data.worker.worker_id}/console`,
)}
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{data.worker.state}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>
{/if}
</section>
@@ -0,0 +1,36 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type { Worker } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const reference = resourceHumanKey(params.workerRef);
const result = await loadJson<Worker>(
fetch,
workspaceApiPath(
params.workspaceId,
`/workers/${encodeURIComponent(reference)}`,
),
);
if (result.data?.human_key) {
const canonical = canonicalResourceReference(
result.data.human_key,
result.data.display_name,
);
if (params.workerRef !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`,
);
}
}
return {
workspaceId: params.workspaceId,
worker: result.data,
workerError: result.error,
};
}) satisfies PageLoad;
@@ -0,0 +1,26 @@
// @ts-nocheck
import {
canonicalResourceReference,
resourceHumanKey,
slugifyResourceTitle,
} from "../src/lib/workspace/resource-links.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`expected ${String(expected)}, received ${String(actual)}`);
}
}
Deno.test("resource links normalize titles and preserve the human key", () => {
assertEquals(slugifyResourceTitle(" Fix stale URL / 日本語 "), "fix-stale-url-日本語");
assertEquals(
canonicalResourceReference("T-1842", "Fix stale URL / 日本語"),
"T-1842-fix-stale-url-日本語",
);
assertEquals(resourceHumanKey("T-1842-fix-stale-url-日本語"), "T-1842");
});
Deno.test("resource links use a deterministic fallback for punctuation-only titles", () => {
assertEquals(canonicalResourceReference("O-7", "---"), "O-7-resource");
assertEquals(resourceHumanKey("01a017internal"), "01a017internal");
});