workspace: use opaque numeric worker ids

This commit is contained in:
Keisuke Hirata 2026-07-12 05:07:39 +09:00
parent 664f8693a0
commit c5aae4b234
No known key found for this signature in database
14 changed files with 509 additions and 258 deletions

View File

@ -0,0 +1,17 @@
---
title: 'Opaque Worker and Workdir ids'
state: 'closed'
created_at: '2026-07-11T10:56:08Z'
updated_at: '2026-07-11T12:05:24Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-07-11T10:56:21Z'
---
## 背景
LocalTicketBackend によって作成されました。
## 受け入れ条件
- 未定

View File

@ -0,0 +1 @@
/tmp/yoi-id-implementation-report.md

View File

@ -0,0 +1,116 @@
<!-- event: create author: "yoi ticket" at: 2026-07-11T10:56:08Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: hare at: 2026-07-11T10:56:21Z -->
## Intake summary
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: planning to: ready reason: cli_state field: state -->
## State changed
Marked ready by `yoi ticket state`.
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T10:56:21Z from: queued to: inprogress reason: cli_state field: state -->
## State changed
State changed to `inprogress`.
---
<!-- event: plan author: hare at: 2026-07-11T10:56:21Z -->
## Plan
Replace meaningful concatenated Worker/Workdir ids with opaque runtime/local ids and composite backend registry keys. Preserve display labels separately, migrate legacy registry/link rows, and validate server/runtime/frontend builds.
---
<!-- event: implementation_report author: hare at: 2026-07-11T11:25:17Z -->
## Implementation report
Implemented opaque Worker/Workdir ids and removed Backend synthetic Worker PKs.
- Runtime generated Worker ids are now opaque fixed-width hex counters without the `worker-` prefix.
- Runtime and Backend generated Workdir ids are now opaque timestamp+sequence hex ids without `backend-`, `alloc-`, repository id, or Runtime/Worker-derived components.
- Workspace Backend worker registry now uses `(workspace_id, runtime_id, runtime_worker_id)` as the primary key.
- Worker/Workdir links now reference the same composite worker key instead of `{runtime_id}/{worker_id}` synthetic strings.
- Added schema v5 migration from the legacy synthetic `worker_id` registry/link tables.
- Updated cleanup, list, sync, retention, and tests to use composite keys and keep public `WorkerSummary.worker_id` Runtime-local.
Validation:
- cargo test -q -p yoi-workspace-server
- cargo test -q
- cd web/workspace && deno task check && deno task test
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T11:25:17Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
---
<!-- event: state_changed author: hare at: 2026-07-11T11:25:17Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T11:25:17Z status: closed -->
## 完了
/tmp/yoi-id-implementation-report.md
---
<!-- event: implementation_report author: hare at: 2026-07-11T12:05:24Z -->
## Implementation report
Follow-up: Worker ids are now numeric, not String-backed.
- `worker_runtime::WorkerId` is now a transparent `u64` newtype instead of `String`.
- Runtime HTTP JSON uses numeric Worker ids; URL path handling parses the path segment as `u64`.
- Workspace Backend stores `runtime_worker_id` as INTEGER in `worker_registry` and `worker_workdir_links` composite keys.
- Browser-facing Workspace `WorkerSummary.worker_id` remains a string route segment derived from the numeric id, so existing frontend route composition stays simple.
- Schema v5 migration converts legacy `worker-*` / numeric string worker ids into integer `runtime_worker_id` values before rebuilding registry/link tables.
---

View File

@ -366,7 +366,7 @@ impl FsRuntimeStore {
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf { fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
self.runtime_dir self.runtime_dir
.join(WORKERS_DIR) .join(WORKERS_DIR)
.join(encoded_component(worker_id.as_str())) .join(encoded_component(&worker_id.to_string()))
} }
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]

View File

@ -692,11 +692,11 @@ async fn cancel_worker(
} }
fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> { fn worker_ref_for(runtime: &Runtime, worker_id: String) -> Result<WorkerRef, RuntimeHttpRestError> {
let worker_id = WorkerId::new(worker_id).ok_or_else(|| { let worker_id = WorkerId::parse(&worker_id).ok_or_else(|| {
RuntimeHttpRestError::new( RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"invalid_worker_id", "invalid_worker_id",
"worker_id must not be empty", "worker_id must be an unsigned integer",
) )
})?; })?;
let runtime_id = runtime let runtime_id = runtime
@ -1107,12 +1107,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn runtime_errors_use_typed_rest_error_shape() { async fn runtime_errors_use_typed_rest_error_shape() {
let app = runtime_http_router(Runtime::new_memory(), None); let app = runtime_http_router(Runtime::new_memory(), None);
let response = empty_request(app, Method::GET, "/v1/workers/worker-missing").await; let response = empty_request(app, Method::GET, "/v1/workers/999").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("worker-missing")); assert!(error.error.message.contains("999"));
} }
} }

View File

@ -36,26 +36,25 @@ impl fmt::Display for RuntimeId {
} }
/// Runtime-local Worker identity. /// Runtime-local Worker identity.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct WorkerId(String); pub struct WorkerId(u64);
impl WorkerId { impl WorkerId {
pub fn new(value: impl Into<String>) -> Option<Self> { pub fn new(value: u64) -> Self {
let value = value.into(); Self(value)
if value.trim().is_empty() { }
None
} else { pub fn parse(value: &str) -> Option<Self> {
Some(Self(value)) value.parse::<u64>().ok().map(Self)
}
} }
pub(crate) fn generated(sequence: u64) -> Self { pub(crate) fn generated(sequence: u64) -> Self {
Self(format!("worker-{sequence:08x}")) Self(sequence)
} }
pub fn as_str(&self) -> &str { pub fn as_u64(&self) -> u64 {
&self.0 self.0
} }
} }

View File

@ -186,7 +186,7 @@ pub fn build_profile_source_archive_fetch_request(
BackendResourceFetchRequest { BackendResourceFetchRequest {
handle, handle,
runtime_id: runtime_id.as_str().to_string(), runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()), worker_id: worker_id.map(|id| id.to_string()),
audit_correlation_id, audit_correlation_id,
} }
} }

View File

@ -1122,13 +1122,11 @@ mod tests {
#[test] #[test]
fn runtime_worker_name_is_namespaced_by_runtime_id() { fn runtime_worker_name_is_namespaced_by_runtime_id() {
let runtime_id = RuntimeId::new("arc:remote".to_string()).unwrap(); let runtime_id = RuntimeId::new("arc:remote".to_string()).unwrap();
let worker_ref = crate::identity::WorkerRef::new( let worker_ref =
runtime_id, crate::identity::WorkerRef::new(runtime_id, crate::identity::WorkerId::new(1));
crate::identity::WorkerId::new("worker-00000001".to_string()).unwrap(),
);
let request = WorkerExecutionSpawnRequest { let request = WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
request: create_request("worker-00000001"), request: create_request("1"),
context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))), context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))),
working_directory: None, working_directory: None,
config_bundle: None, config_bundle: None,
@ -1136,11 +1134,11 @@ mod tests {
assert_eq!( assert_eq!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request), ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
"runtime-arc-remote-worker-00000001" "runtime-arc-remote-1"
); );
assert_ne!( assert_ne!(
ProfileRuntimeWorkerFactory::runtime_worker_name(&request), ProfileRuntimeWorkerFactory::runtime_worker_name(&request),
"worker-00000001" "00000001"
); );
} }

View File

@ -167,13 +167,8 @@ impl LocalGitWorktreeMaterializer {
&self.runtime_root &self.runtime_root
} }
fn working_directory_id(worker_ref: &WorkerRef, repository_id: &str) -> String { fn working_directory_id(_worker_ref: &WorkerRef, repository_id: &str) -> String {
format!( next_working_directory_id(repository_id)
"{}-{}-{}",
sanitize_path_component(worker_ref.runtime_id.as_str()),
sanitize_path_component(worker_ref.worker_id.as_str()),
sanitize_path_component(repository_id)
)
} }
fn working_directory_root(&self, working_directory_id: &str) -> PathBuf { fn working_directory_root(&self, working_directory_id: &str) -> PathBuf {
@ -609,16 +604,13 @@ fn sanitize_path_component(value: &str) -> String {
} }
} }
fn next_working_directory_id(repository_id: &str) -> String { fn next_working_directory_id(_repository_id: &str) -> String {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis()) .map(|duration| duration.as_millis() as u64)
.unwrap_or_default(); .unwrap_or_default();
let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); let sequence = NEXT_WORKING_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0x00ff_ffff;
format!( format!("{now:013x}{sequence:06x}")
"alloc-{now}-{sequence}-{}",
sanitize_path_component(repository_id)
)
} }
fn validate_working_directory_id( fn validate_working_directory_id(

View File

@ -1158,7 +1158,7 @@ impl EmbeddedWorkerRuntime {
fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> { fn worker_ref(&self, worker_id: &str) -> Option<EmbeddedWorkerRef> {
Some(EmbeddedWorkerRef::new( Some(EmbeddedWorkerRef::new(
EmbeddedRuntimeId::new(self.runtime_id.clone())?, EmbeddedRuntimeId::new(self.runtime_id.clone())?,
EmbeddedWorkerId::new(worker_id.to_string())?, EmbeddedWorkerId::parse(worker_id)?,
)) ))
} }
@ -1181,9 +1181,9 @@ impl EmbeddedWorkerRuntime {
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary { fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
WorkerSummary { WorkerSummary {
runtime_id: self.runtime_id.clone(), runtime_id: self.runtime_id.clone(),
worker_id: summary.worker_ref.worker_id.as_str().to_string(), worker_id: summary.worker_ref.worker_id.to_string(),
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()), label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
role: embedded_profile_label(&summary.profile), role: embedded_profile_label(&summary.profile),
profile: embedded_profile_label(&summary.profile), profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary { workspace: WorkerWorkspaceSummary {
@ -1217,9 +1217,9 @@ impl EmbeddedWorkerRuntime {
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary { fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
WorkerSummary { WorkerSummary {
runtime_id: self.runtime_id.clone(), runtime_id: self.runtime_id.clone(),
worker_id: detail.worker_id.as_str().to_string(), worker_id: detail.worker_id.to_string(),
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()), label: safe_display_hint(&detail.worker_id.to_string()),
role: embedded_profile_label(&detail.profile), role: embedded_profile_label(&detail.profile),
profile: embedded_profile_label(&detail.profile), profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary { workspace: WorkerWorkspaceSummary {
@ -1919,9 +1919,9 @@ impl RemoteWorkerRuntime {
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary { fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
WorkerSummary { WorkerSummary {
runtime_id: self.runtime_id.clone(), runtime_id: self.runtime_id.clone(),
worker_id: summary.worker_ref.worker_id.as_str().to_string(), worker_id: summary.worker_ref.worker_id.to_string(),
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
label: safe_display_hint(summary.worker_ref.worker_id.as_str()), label: safe_display_hint(&summary.worker_ref.worker_id.to_string()),
role: None, role: None,
profile: embedded_profile_label(&summary.profile), profile: embedded_profile_label(&summary.profile),
workspace: WorkerWorkspaceSummary { workspace: WorkerWorkspaceSummary {
@ -1954,9 +1954,9 @@ impl RemoteWorkerRuntime {
fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary { fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary {
WorkerSummary { WorkerSummary {
runtime_id: self.runtime_id.clone(), runtime_id: self.runtime_id.clone(),
worker_id: detail.worker_id.as_str().to_string(), worker_id: detail.worker_id.to_string(),
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
label: safe_display_hint(detail.worker_id.as_str()), label: safe_display_hint(&detail.worker_id.to_string()),
role: None, role: None,
profile: embedded_profile_label(&detail.profile), profile: embedded_profile_label(&detail.profile),
workspace: WorkerWorkspaceSummary { workspace: WorkerWorkspaceSummary {
@ -3562,18 +3562,18 @@ mod tests {
Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing" Error::UnknownRuntime(runtime_id) if runtime_id == "runtime-missing"
)); ));
let unknown_worker = registry.worker("runtime-a", "worker-missing").unwrap_err(); let unknown_worker = registry.worker("runtime-a", "999").unwrap_err();
assert_eq!( assert_eq!(
unknown_worker, unknown_worker,
RuntimeRegistryError::UnknownWorker { RuntimeRegistryError::UnknownWorker {
runtime_id: "runtime-a".to_string(), runtime_id: "runtime-a".to_string(),
worker_id: "worker-missing".to_string(), worker_id: "999".to_string(),
} }
); );
assert!(matches!( assert!(matches!(
unknown_worker.into_error(), unknown_worker.into_error(),
Error::UnknownWorker { runtime_id, worker_id } Error::UnknownWorker { runtime_id, worker_id }
if runtime_id == "runtime-a" && worker_id == "worker-missing" if runtime_id == "runtime-a" && worker_id == "999"
)); ));
} }
@ -3881,7 +3881,7 @@ 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", "worker-remote-1"); let worker_json = worker_json("remote:primary", "1");
let (base_url, server) = serve_mock_http(vec![ let (base_url, server) = serve_mock_http(vec![
mock_response( mock_response(
"GET", "GET",
@ -3892,19 +3892,19 @@ mod tests {
), ),
mock_response( mock_response(
"GET", "GET",
"/v1/workers/worker-remote-1", "/v1/workers/1",
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/worker-remote-1/input", "/v1/workers/1/input",
true, true,
200, 200,
json!({ json!({
"ack": { "ack": {
"worker_ref": { "runtime_id": "remote:primary", "worker_id": "worker-remote-1" }, "worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
"status": "running", "status": "running",
"event_id": 8 "event_id": 8
} }
@ -3929,24 +3929,20 @@ mod tests {
); );
let observation = registry let observation = registry
.observation_source("remote:primary", "worker-remote-1") .observation_source("remote:primary", "1")
.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!( assert!(observation.endpoint.ends_with("/v1/workers/1/events/ws"));
observation
.endpoint
.ends_with("/v1/workers/worker-remote-1/events/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].runtime_id, "remote:primary"); assert_eq!(workers.items[0].runtime_id, "remote:primary");
assert_eq!(workers.items[0].worker_id, "worker-remote-1"); assert_eq!(workers.items[0].worker_id, "1");
assert_eq!( assert_eq!(
workers.items[0].implementation.kind, workers.items[0].implementation.kind,
"remote_worker_runtime" "remote_worker_runtime"
@ -3961,7 +3957,7 @@ mod tests {
let input = registry let input = registry
.send_input( .send_input(
"remote:primary", "remote:primary",
"worker-remote-1", "1",
WorkerInputRequest { WorkerInputRequest {
kind: WorkerInputKind::User, kind: WorkerInputKind::User,
content: "hello remote".to_string(), content: "hello remote".to_string(),
@ -3997,28 +3993,28 @@ mod tests {
"workers": [ "workers": [
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"worker-stale", "1",
"stale", "stale",
"unconnected", "unconnected",
None, None,
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"worker-unconnected", "2",
"unconnected", "unconnected",
"unconnected", "unconnected",
None, None,
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"worker-rejected", "3",
"connected", "connected",
"rejected", "rejected",
Some("rejected"), Some("rejected"),
), ),
worker_json_with_execution( worker_json_with_execution(
"remote:primary", "remote:primary",
"worker-errored", "4",
"connected", "connected",
"errored", "errored",
Some("errored"), Some("errored"),
@ -4029,13 +4025,13 @@ mod tests {
), ),
mock_response( mock_response(
"GET", "GET",
"/v1/workers/worker-stale", "/v1/workers/1",
true, true,
200, 200,
json!({ json!({
"worker": worker_json_with_execution( "worker": worker_json_with_execution(
"remote:primary", "remote:primary",
"worker-stale", "1",
"stale", "stale",
"unconnected", "unconnected",
None, None,
@ -4076,7 +4072,7 @@ mod tests {
assert_eq!(workers.items[2].state, "rejected"); assert_eq!(workers.items[2].state, "rejected");
assert_eq!(workers.items[3].state, "errored"); assert_eq!(workers.items[3].state, "errored");
let stale_detail = registry.worker("remote:primary", "worker-stale").unwrap(); let stale_detail = registry.worker("remote:primary", "1").unwrap();
assert!(!stale_detail.capabilities.can_accept_input); assert!(!stale_detail.capabilities.can_accept_input);
assert!(!stale_detail.capabilities.can_stop); assert!(!stale_detail.capabilities.can_stop);
assert_eq!(stale_detail.state, "stale"); assert_eq!(stale_detail.state, "stale");
@ -4162,7 +4158,7 @@ mod tests {
fn remote_runtime_auth_errors_map_to_typed_backend_error() { fn remote_runtime_auth_errors_map_to_typed_backend_error() {
let (base_url, server) = serve_mock_http(vec![mock_response( let (base_url, server) = serve_mock_http(vec![mock_response(
"GET", "GET",
"/v1/workers/worker-missing", "/v1/workers/999",
true, true,
401, 401,
json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(), json!({ "error": { "code": "unauthorized", "message": "bad token" } }).to_string(),
@ -4183,7 +4179,7 @@ mod tests {
); );
let error = registry let error = registry
.worker("remote:primary", "worker-missing") .worker("remote:primary", "999")
.expect_err("auth failure is a backend operation error"); .expect_err("auth failure is a backend operation error");
assert!(matches!( assert!(matches!(
error, error,
@ -4278,6 +4274,7 @@ mod tests {
"message": format!("{outcome} result") "message": format!("{outcome} result")
}) })
}); });
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,

View File

@ -42,7 +42,7 @@ impl BackendResourceBroker {
workspace_id: workspace_id.clone(), workspace_id: workspace_id.clone(),
scope_id: Some("workspace-profile-source".to_string()), scope_id: Some("workspace-profile-source".to_string()),
runtime_id: runtime_id.map(|id| id.as_str().to_string()), runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()), worker_id: worker_id.map(|id| id.to_string()),
resource_id: archive.reference.id.clone(), resource_id: archive.reference.id.clone(),
digest: archive.reference.digest.clone(), digest: archive.reference.digest.clone(),
operation: BackendResourceOperation::FetchArchive, operation: BackendResourceOperation::FetchArchive,
@ -58,7 +58,7 @@ impl BackendResourceBroker {
}; };
let stored = StoredResource { let stored = StoredResource {
runtime_id: runtime_id.map(|id| id.as_str().to_string()), runtime_id: runtime_id.map(|id| id.as_str().to_string()),
worker_id: worker_id.map(|id| id.as_str().to_string()), worker_id: worker_id.map(|id| id.to_string()),
handle: handle.clone(), handle: handle.clone(),
archive, archive,
}; };
@ -221,7 +221,7 @@ mod tests {
audit_correlation_id: handle.audit_correlation_id.clone(), audit_correlation_id: handle.audit_correlation_id.clone(),
handle, handle,
runtime_id: runtime_id.as_str().to_string(), runtime_id: runtime_id.as_str().to_string(),
worker_id: worker_id.map(|id| id.as_str().to_string()), worker_id: worker_id.map(|id| id.to_string()),
} }
} }
@ -271,8 +271,8 @@ mod tests {
fn broker_rejects_worker_mismatch() { fn broker_rejects_worker_mismatch() {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = RuntimeId::new("runtime-test").unwrap(); let runtime_id = RuntimeId::new("runtime-test").unwrap();
let worker_a = WorkerId::new("worker-a").unwrap(); let worker_a = WorkerId::new(1);
let worker_b = WorkerId::new("worker-b").unwrap(); let worker_b = WorkerId::new(2);
let handle = broker.issue_profile_source_archive_handle( let handle = broker.issue_profile_source_archive_handle(
"workspace-test", "workspace-test",
Some(&runtime_id), Some(&runtime_id),

View File

@ -1,5 +1,6 @@
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path as AxumPath, Query, State}; use axum::extract::{Path as AxumPath, Query, State};
@ -1539,10 +1540,14 @@ async fn set_worker_retention(
runtime_worker_id: String, runtime_worker_id: String,
pinned: bool, pinned: bool,
) -> ApiResult<Json<WorkerRetentionResponse>> { ) -> ApiResult<Json<WorkerRetentionResponse>> {
let worker_id = backend_worker_id(runtime_id.as_str(), runtime_worker_id.as_str()); let runtime_worker_registry_id = parse_runtime_worker_id_for_registry(&runtime_worker_id)?;
if api if api
.store .store
.get_worker_registry(&api.config.workspace_id, worker_id.as_str())? .get_worker_registry(
&api.config.workspace_id,
runtime_id.as_str(),
runtime_worker_registry_id,
)?
.is_none() .is_none()
{ {
if let Ok(worker) = api if let Ok(worker) = api
@ -1555,7 +1560,8 @@ async fn set_worker_retention(
let retention_state = if pinned { "pinned" } else { "normal" }; let retention_state = if pinned { "pinned" } else { "normal" };
let changed = api.store.update_worker_retention( let changed = api.store.update_worker_retention(
&api.config.workspace_id, &api.config.workspace_id,
worker_id.as_str(), runtime_id.as_str(),
runtime_worker_registry_id,
retention_state, retention_state,
now_registry_timestamp().as_str(), now_registry_timestamp().as_str(),
)?; )?;
@ -1580,11 +1586,15 @@ fn build_runtime_cleanup_plan(
runtime_id: &str, runtime_id: &str,
) -> ApiResult<RuntimeCleanupPlanResponse> { ) -> ApiResult<RuntimeCleanupPlanResponse> {
let workers = workers_response(api.clone())?; let workers = workers_response(api.clone())?;
let live_running_worker_ids: HashSet<String> = workers let live_running_worker_ids: HashSet<(String, u64)> = workers
.items .items
.iter() .iter()
.filter(|worker| worker.state == "running") .filter(|worker| worker.state == "running")
.map(|worker| backend_worker_id(worker.runtime_id.as_str(), worker.worker_id.as_str())) .filter_map(|worker| {
parse_runtime_worker_id_for_registry(worker.worker_id.as_str())
.ok()
.map(|worker_id| (worker.runtime_id.clone(), worker_id))
})
.collect(); .collect();
let (workdir_summaries, mut diagnostics) = let (workdir_summaries, mut diagnostics) =
match runtime_working_directory_summaries(api, runtime_id) { match runtime_working_directory_summaries(api, runtime_id) {
@ -1609,7 +1619,12 @@ fn build_runtime_cleanup_plan(
.list_worker_registry(&api.config.workspace_id, 500)?; .list_worker_registry(&api.config.workspace_id, 500)?;
let worker_by_id: HashMap<_, _> = worker_records let worker_by_id: HashMap<_, _> = worker_records
.iter() .iter()
.map(|record| (record.worker_id.clone(), record.clone())) .map(|record| {
(
(record.runtime_id.clone(), record.runtime_worker_id.clone()),
record.clone(),
)
})
.collect(); .collect();
let observed_workdirs: HashMap<_, _> = workdir_summaries let observed_workdirs: HashMap<_, _> = workdir_summaries
.into_iter() .into_iter()
@ -1621,10 +1636,13 @@ fn build_runtime_cleanup_plan(
.iter() .iter()
.filter(|record| record.runtime_id == runtime_id) .filter(|record| record.runtime_id == runtime_id)
{ {
let links = api let links = api.store.list_worker_workdir_links(
.store &api.config.workspace_id,
.list_worker_workdir_links(&api.config.workspace_id, record.worker_id.as_str())?; record.runtime_id.as_str(),
let is_running = live_running_worker_ids.contains(&record.worker_id); record.runtime_worker_id,
)?;
let is_running = live_running_worker_ids
.contains(&(record.runtime_id.clone(), record.runtime_worker_id.clone()));
let pinned = record.retention_state == "pinned"; let pinned = record.retention_state == "pinned";
let blocking_reason = if pinned { let blocking_reason = if pinned {
Some("worker is pinned".to_string()) Some("worker is pinned".to_string())
@ -1634,10 +1652,14 @@ fn build_runtime_cleanup_plan(
None None
}; };
worker_candidates.push(CleanupWorkerCandidate { worker_candidates.push(CleanupWorkerCandidate {
target_id: format!("worker:{}", record.worker_id), target_id: format!(
"worker:{}:{}",
encode_path_segment(record.runtime_id.as_str()),
encode_path_segment(&record.runtime_worker_id.to_string())
),
action: CleanupTargetKind::WorkerDelete, action: CleanupTargetKind::WorkerDelete,
worker_id: record.worker_id.clone(), worker_id: record.runtime_worker_id.to_string(),
runtime_worker_id: record.runtime_worker_id.clone(), runtime_worker_id: record.runtime_worker_id.to_string(),
runtime_id: record.runtime_id.clone(), runtime_id: record.runtime_id.clone(),
reason: if blocking_reason.is_some() { reason: if blocking_reason.is_some() {
"Worker registry row cannot be deleted until blocking conditions are cleared" "Worker registry row cannot be deleted until blocking conditions are cleared"
@ -1664,16 +1686,21 @@ fn build_runtime_cleanup_plan(
.list_workdir_worker_links(&api.config.workspace_id, record.workdir_id.as_str())?; .list_workdir_worker_links(&api.config.workspace_id, record.workdir_id.as_str())?;
let linked_workers = links let linked_workers = links
.iter() .iter()
.filter_map(|link| worker_by_id.get(link.worker_id.as_str())) .filter_map(|link| {
worker_by_id.get(&(link.runtime_id.clone(), link.runtime_worker_id.clone()))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let linked_worker_ids = links let linked_worker_ids = links
.iter() .iter()
.map(|link| link.worker_id.clone()) .map(|link| link.runtime_worker_id.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let linked_running_worker_ids = linked_workers let linked_running_worker_ids = linked_workers
.iter() .iter()
.filter(|worker| live_running_worker_ids.contains(&worker.worker_id)) .filter(|worker| {
.map(|worker| worker.worker_id.clone()) live_running_worker_ids
.contains(&(worker.runtime_id.clone(), worker.runtime_worker_id.clone()))
})
.map(|worker| worker.runtime_worker_id.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let pinned_linked = linked_workers let pinned_linked = linked_workers
.iter() .iter()
@ -1812,8 +1839,12 @@ fn execute_runtime_cleanup(
"pinned Worker/history cannot be deleted", "pinned Worker/history cannot be deleted",
)); ));
} }
api.store let runtime_worker_id = parse_runtime_worker_id_for_registry(&candidate.runtime_worker_id)?;
.delete_worker_registry(&api.config.workspace_id, candidate.worker_id.as_str())?; api.store.delete_worker_registry(
&api.config.workspace_id,
candidate.runtime_id.as_str(),
runtime_worker_id,
)?;
results.push(RuntimeCleanupExecutionResult { results.push(RuntimeCleanupExecutionResult {
target_id: candidate.target_id.clone(), target_id: candidate.target_id.clone(),
action: candidate.action.clone(), action: candidate.action.clone(),
@ -2697,7 +2728,11 @@ async fn create_workspace_worker(
management_kind.as_str(), management_kind.as_str(),
); );
api.store.upsert_workdir_registry(&workdir_record)?; api.store.upsert_workdir_registry(&workdir_record)?;
link_worker_to_workdir(&api, &worker_record, &working_directory.working_directory_id)?; link_worker_to_workdir(
&api,
&worker_record,
&working_directory.working_directory_id,
)?;
} }
if let Some(workdir_id) = selected_working_directory_id.as_deref() { if let Some(workdir_id) = selected_working_directory_id.as_deref() {
if api if api
@ -2813,9 +2848,11 @@ async fn get_runtime_worker(
.worker(&runtime_id, &worker_id) .worker(&runtime_id, &worker_id)
.map_err(|err| err.into_error())?; .map_err(|err| err.into_error())?;
let record = sync_worker_observation(&api, &worker)?; let record = sync_worker_observation(&api, &worker)?;
let links = api let links = api.store.list_worker_workdir_links(
.store &api.config.workspace_id,
.list_worker_workdir_links(&api.config.workspace_id, record.worker_id.as_str())?; record.runtime_id.as_str(),
record.runtime_worker_id,
)?;
let workdirs = api let workdirs = api
.store .store
.list_workdir_registry(&api.config.workspace_id, 500)?; .list_workdir_registry(&api.config.workspace_id, 500)?;
@ -2981,10 +3018,10 @@ async fn stop_runtime_worker(
.runtime .runtime
.stop_worker(&runtime_id, &worker_id, request) .stop_worker(&runtime_id, &worker_id, request)
.map_err(|err| err.into_error())?; .map_err(|err| err.into_error())?;
let backend_id = backend_worker_id(&runtime_id, &worker_id); let runtime_worker_id = parse_runtime_worker_id_for_registry(&worker_id)?;
if let Some(record) = api if let Some(record) =
.store api.store
.get_worker_registry(&api.config.workspace_id, backend_id.as_str())? .get_worker_registry(&api.config.workspace_id, &runtime_id, runtime_worker_id)?
{ {
sync_linked_workdir_after_worker_stop(&api, &runtime_id, &record)?; sync_linked_workdir_after_worker_stop(&api, &runtime_id, &record)?;
} }
@ -3137,10 +3174,14 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
let mut observed = std::collections::BTreeMap::new(); let mut observed = std::collections::BTreeMap::new();
for worker in &runtime_workers.items { for worker in &runtime_workers.items {
let _ = sync_worker_observation(&api, worker); let _ = sync_worker_observation(&api, worker);
observed.insert( if let Ok(runtime_worker_id) =
backend_worker_id(worker.runtime_id.as_str(), worker.worker_id.as_str()), parse_runtime_worker_id_for_registry(worker.worker_id.as_str())
worker.clone(), {
); observed.insert(
(worker.runtime_id.clone(), runtime_worker_id),
worker.clone(),
);
}
} }
let worker_records = api let worker_records = api
.store .store
@ -3150,11 +3191,13 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
.list_workdir_registry(&api.config.workspace_id, 500)?; .list_workdir_registry(&api.config.workspace_id, 500)?;
let mut items = Vec::new(); let mut items = Vec::new();
for record in worker_records { for record in worker_records {
let links = api let links = api.store.list_worker_workdir_links(
.store &api.config.workspace_id,
.list_worker_workdir_links(&api.config.workspace_id, record.worker_id.as_str())?; record.runtime_id.as_str(),
record.runtime_worker_id,
)?;
items.push(merge_worker_registry_projection( items.push(merge_worker_registry_projection(
observed.get(record.worker_id.as_str()), observed.get(&(record.runtime_id.clone(), record.runtime_worker_id.clone())),
&record, &record,
links, links,
&workdir_records, &workdir_records,
@ -3518,7 +3561,7 @@ async fn test_remote_runtime_config(
if let Some(worker) = workers.as_ref().and_then(|workers| workers.workers.first()) { if let Some(worker) = workers.as_ref().and_then(|workers| workers.workers.first()) {
let path = format!( let path = format!(
"/v1/workers/{}", "/v1/workers/{}",
encode_path_segment(worker.worker_id.as_str()) encode_path_segment(&worker.worker_id.to_string())
); );
match remote_probe_url(remote, &path) { match remote_probe_url(remote, &path) {
Ok(url) => match probe_remote_json(&client, url, "workers.detail", "Worker detail").await { Ok(url) => match probe_remote_json(&client, url, "workers.detail", "Worker detail").await {
@ -3890,9 +3933,7 @@ fn runtime_working_directory_summaries(
Ok((items, diagnostics)) Ok((items, diagnostics))
} }
fn backend_worker_id(runtime_id: &str, runtime_worker_id: &str) -> String { static BACKEND_WORKDIR_ID_SEQUENCE: AtomicU64 = AtomicU64::new(0);
format!("{runtime_id}/{runtime_worker_id}")
}
fn now_registry_timestamp() -> String { fn now_registry_timestamp() -> String {
std::time::SystemTime::now() std::time::SystemTime::now()
@ -3901,31 +3942,13 @@ fn now_registry_timestamp() -> String {
.unwrap_or_else(|_| "0".to_string()) .unwrap_or_else(|_| "0".to_string())
} }
fn registry_safe_id_component(value: &str) -> String { fn next_backend_workdir_id(_repository_id: &str) -> String {
let sanitized: String = value let timestamp_ms = std::time::SystemTime::now()
.chars() .duration_since(std::time::UNIX_EPOCH)
.map(|ch| { .map(|duration| duration.as_millis() as u64)
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { .unwrap_or(0);
ch let sequence = BACKEND_WORKDIR_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0x00ff_ffff;
} else { format!("{timestamp_ms:013x}{sequence:06x}")
'-'
}
})
.collect();
sanitized.trim_matches('-').chars().take(48).collect()
}
fn next_backend_workdir_id(repository_id: &str) -> String {
let repository = registry_safe_id_component(repository_id);
format!(
"backend-{}-{}",
now_registry_timestamp(),
if repository.is_empty() {
"workdir"
} else {
&repository
}
)
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -3942,10 +3965,12 @@ fn record_worker_summary(
display_name_policy: WorkerRegistryDisplayNamePolicy, display_name_policy: WorkerRegistryDisplayNamePolicy,
) -> ApiResult<WorkerRegistryRecord> { ) -> ApiResult<WorkerRegistryRecord> {
let timestamp = now_registry_timestamp(); let timestamp = now_registry_timestamp();
let worker_id = backend_worker_id(worker.runtime_id.as_str(), worker.worker_id.as_str()); let runtime_worker_id = parse_runtime_worker_id_for_registry(worker.worker_id.as_str())?;
let existing = api let existing = api.store.get_worker_registry(
.store &api.config.workspace_id,
.get_worker_registry(&api.config.workspace_id, worker_id.as_str())?; worker.runtime_id.as_str(),
runtime_worker_id,
)?;
let display_name = match (display_name_policy, existing.as_ref()) { let display_name = match (display_name_policy, existing.as_ref()) {
(WorkerRegistryDisplayNamePolicy::PreserveExisting, Some(record)) => { (WorkerRegistryDisplayNamePolicy::PreserveExisting, Some(record)) => {
record.display_name.clone() record.display_name.clone()
@ -3954,9 +3979,8 @@ fn record_worker_summary(
}; };
let record = WorkerRegistryRecord { let record = WorkerRegistryRecord {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
worker_id: worker_id.clone(),
runtime_id: worker.runtime_id.as_str().to_string(), runtime_id: worker.runtime_id.as_str().to_string(),
runtime_worker_id: worker.worker_id.as_str().to_string(), runtime_worker_id,
display_name, display_name,
profile, profile,
retention_state: existing retention_state: existing
@ -3977,13 +4001,17 @@ fn record_worker_summary(
api.store.upsert_worker_registry(&record)?; api.store.upsert_worker_registry(&record)?;
Ok(api Ok(api
.store .store
.get_worker_registry(&api.config.workspace_id, worker_id.as_str())? .get_worker_registry(
&api.config.workspace_id,
worker.runtime_id.as_str(),
runtime_worker_id,
)?
.unwrap_or(record)) .unwrap_or(record))
} }
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary { fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
WorkerSummary { WorkerSummary {
worker_id: record.runtime_worker_id.clone(), worker_id: record.runtime_worker_id.to_string(),
runtime_id: record.runtime_id.clone(), runtime_id: record.runtime_id.clone(),
host_id: "backend-registry".to_string(), host_id: "backend-registry".to_string(),
role: None, role: None,
@ -4159,9 +4187,11 @@ fn sync_linked_workdir_after_worker_stop(
runtime_id: &str, runtime_id: &str,
worker_record: &WorkerRegistryRecord, worker_record: &WorkerRegistryRecord,
) -> ApiResult<()> { ) -> ApiResult<()> {
let links = api let links = api.store.list_worker_workdir_links(
.store &api.config.workspace_id,
.list_worker_workdir_links(&api.config.workspace_id, worker_record.worker_id.as_str())?; worker_record.runtime_id.as_str(),
worker_record.runtime_worker_id,
)?;
for link in links { for link in links {
let result = api let result = api
.runtime .runtime
@ -4253,7 +4283,8 @@ fn link_worker_to_workdir(
api.store api.store
.upsert_worker_workdir_link(&WorkerWorkdirLinkRecord { .upsert_worker_workdir_link(&WorkerWorkdirLinkRecord {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
worker_id: worker_record.worker_id.clone(), runtime_id: worker_record.runtime_id.clone(),
runtime_worker_id: worker_record.runtime_worker_id.clone(),
workdir_id: workdir_id.to_string(), workdir_id: workdir_id.to_string(),
role: "primary_cwd".to_string(), role: "primary_cwd".to_string(),
linked_at: timestamp, linked_at: timestamp,
@ -4380,6 +4411,15 @@ fn profile_selector_for_candidate_with_root(
} }
} }
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> {
worker_id.parse::<u64>().map_err(|_| {
settings_bad_request(
"workspace_worker_id_invalid",
"Runtime Worker id must be an unsigned integer",
)
})
}
fn sanitize_worker_display_name(value: &str) -> Option<String> { fn sanitize_worker_display_name(value: &str) -> Option<String> {
let display_name = value.trim(); let display_name = value.trim();
if display_name.chars().any(char::is_control) { if display_name.chars().any(char::is_control) {
@ -4837,9 +4877,8 @@ mod tests {
fn backend_worker_projection_preserves_archive_rows_links_and_redacts_paths() { fn backend_worker_projection_preserves_archive_rows_links_and_redacts_paths() {
let worker = WorkerRegistryRecord { let worker = WorkerRegistryRecord {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
worker_id: "embedded/worker-1".to_string(),
runtime_id: "embedded".to_string(), runtime_id: "embedded".to_string(),
runtime_worker_id: "worker-1".to_string(), runtime_worker_id: 1,
display_name: "Archived Worker".to_string(), display_name: "Archived Worker".to_string(),
profile: Some("builtin:coder".to_string()), profile: Some("builtin:coder".to_string()),
retention_state: "pinned".to_string(), retention_state: "pinned".to_string(),
@ -4852,7 +4891,7 @@ mod tests {
}; };
let workdir = WorkdirRegistryRecord { let workdir = WorkdirRegistryRecord {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
workdir_id: "backend-1-repo".to_string(), workdir_id: "0000019a00000000000".to_string(),
runtime_id: "embedded".to_string(), runtime_id: "embedded".to_string(),
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
selector: Some("develop".to_string()), selector: Some("develop".to_string()),
@ -4865,7 +4904,8 @@ mod tests {
}; };
let link = WorkerWorkdirLinkRecord { let link = WorkerWorkdirLinkRecord {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
worker_id: worker.worker_id.clone(), runtime_id: worker.runtime_id.clone(),
runtime_worker_id: worker.runtime_worker_id.clone(),
workdir_id: workdir.workdir_id.clone(), workdir_id: workdir.workdir_id.clone(),
role: "primary_cwd".to_string(), role: "primary_cwd".to_string(),
linked_at: "4".to_string(), linked_at: "4".to_string(),
@ -5423,17 +5463,15 @@ mod tests {
fn seed_cleanup_worker( fn seed_cleanup_worker(
api: &WorkspaceApi, api: &WorkspaceApi,
runtime_worker_id: &str, runtime_worker_id: u64,
retention_state: &str, retention_state: &str,
) -> String { ) -> String {
let worker_id = backend_worker_id("runtime-test", runtime_worker_id);
let now = now_registry_timestamp(); let now = now_registry_timestamp();
api.store api.store
.upsert_worker_registry(&WorkerRegistryRecord { .upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
worker_id: worker_id.clone(),
runtime_id: "runtime-test".to_string(), runtime_id: "runtime-test".to_string(),
runtime_worker_id: runtime_worker_id.to_string(), runtime_worker_id,
display_name: runtime_worker_id.to_string(), display_name: runtime_worker_id.to_string(),
profile: None, profile: None,
retention_state: retention_state.to_string(), retention_state: retention_state.to_string(),
@ -5445,7 +5483,7 @@ mod tests {
updated_at: now, updated_at: now,
}) })
.unwrap(); .unwrap();
worker_id runtime_worker_id.to_string()
} }
fn seed_cleanup_workdir(api: &WorkspaceApi, workdir_id: &str, status: &str, cleanliness: &str) { fn seed_cleanup_workdir(api: &WorkspaceApi, workdir_id: &str, status: &str, cleanliness: &str) {
@ -5467,11 +5505,13 @@ mod tests {
.unwrap(); .unwrap();
} }
fn seed_cleanup_link(api: &WorkspaceApi, worker_id: &str, workdir_id: &str) { fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
api.store api.store
.upsert_worker_workdir_link(&WorkerWorkdirLinkRecord { .upsert_worker_workdir_link(&WorkerWorkdirLinkRecord {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
worker_id: worker_id.to_string(), runtime_id: "runtime-test".to_string(),
runtime_worker_id,
workdir_id: workdir_id.to_string(), workdir_id: workdir_id.to_string(),
role: "primary".to_string(), role: "primary".to_string(),
linked_at: now_registry_timestamp(), linked_at: now_registry_timestamp(),
@ -5606,8 +5646,8 @@ mod tests {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path()); init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
let pinned = seed_cleanup_worker(&api, "worker-pinned", "pinned"); let pinned = seed_cleanup_worker(&api, 1, "pinned");
let unobserved = seed_cleanup_worker(&api, "worker-unobserved", "normal"); let unobserved = seed_cleanup_worker(&api, 2, "normal");
seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty"); seed_cleanup_workdir(&api, "workdir-dirty", "present", "dirty");
seed_cleanup_workdir(&api, "workdir-removed", "removed", "clean"); seed_cleanup_workdir(&api, "workdir-removed", "removed", "clean");
seed_cleanup_link(&api, pinned.as_str(), "workdir-dirty"); seed_cleanup_link(&api, pinned.as_str(), "workdir-dirty");
@ -5652,7 +5692,7 @@ mod tests {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path()); init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
let worker = seed_cleanup_worker(&api, "worker-pinned", "pinned"); let worker = seed_cleanup_worker(&api, 1, "pinned");
let plan = build_runtime_cleanup_plan(&api, "runtime-test") let plan = build_runtime_cleanup_plan(&api, "runtime-test")
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
let target = plan let target = plan

View File

@ -37,6 +37,11 @@ const MIGRATIONS: &[Migration] = &[
name: "remove durable worker lifecycle state", name: "remove durable worker lifecycle state",
apply: remove_worker_registry_legacy_live_state_column, apply: remove_worker_registry_legacy_live_state_column,
}, },
Migration {
version: 5,
name: "use composite worker registry keys",
apply: use_composite_worker_registry_keys,
},
]; ];
struct Migration { struct Migration {
@ -57,10 +62,8 @@ pub struct WorkspaceRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerRegistryRecord { pub struct WorkerRegistryRecord {
pub workspace_id: String, pub workspace_id: String,
/// Backend-owned archival Worker id. In v0 it is derived from runtime_id + runtime_worker_id.
pub worker_id: String,
pub runtime_id: String, pub runtime_id: String,
pub runtime_worker_id: String, pub runtime_worker_id: u64,
pub display_name: String, pub display_name: String,
pub profile: Option<String>, pub profile: Option<String>,
/// Retention state is explicit so `pinned` can be represented before prune exists. /// Retention state is explicit so `pinned` can be represented before prune exists.
@ -92,7 +95,8 @@ pub struct WorkdirRegistryRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerWorkdirLinkRecord { pub struct WorkerWorkdirLinkRecord {
pub workspace_id: String, pub workspace_id: String,
pub worker_id: String, pub runtime_id: String,
pub runtime_worker_id: u64,
pub workdir_id: String, pub workdir_id: String,
pub role: String, pub role: String,
pub linked_at: String, pub linked_at: String,
@ -107,15 +111,10 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>; fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
fn get_worker_registry( fn get_worker_registry(
&self,
workspace_id: &str,
worker_id: &str,
) -> Result<Option<WorkerRegistryRecord>>;
fn get_worker_registry_by_runtime(
&self, &self,
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
runtime_worker_id: &str, runtime_worker_id: u64,
) -> Result<Option<WorkerRegistryRecord>>; ) -> Result<Option<WorkerRegistryRecord>>;
fn list_worker_registry( fn list_worker_registry(
&self, &self,
@ -125,11 +124,17 @@ pub trait ControlPlaneStore: Send + Sync {
fn update_worker_retention( fn update_worker_retention(
&self, &self,
workspace_id: &str, workspace_id: &str,
worker_id: &str, runtime_id: &str,
runtime_worker_id: u64,
retention_state: &str, retention_state: &str,
updated_at: &str, updated_at: &str,
) -> Result<bool>; ) -> Result<bool>;
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool>; fn delete_worker_registry(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<bool>;
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>; fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
fn get_workdir_registry( fn get_workdir_registry(
@ -153,7 +158,8 @@ pub trait ControlPlaneStore: Send + Sync {
fn list_worker_workdir_links( fn list_worker_workdir_links(
&self, &self,
workspace_id: &str, workspace_id: &str,
worker_id: &str, runtime_id: &str,
runtime_worker_id: u64,
) -> Result<Vec<WorkerWorkdirLinkRecord>>; ) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_workdir_worker_links( fn list_workdir_worker_links(
&self, &self,
@ -247,13 +253,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
r#"INSERT INTO worker_registry ( r#"INSERT INTO worker_registry (
workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, workspace_id, runtime_id, runtime_worker_id, display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref, retention_state, transcript_ref, session_ref, summary_ref,
diagnostics_ref, created_at, updated_at diagnostics_ref, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(workspace_id, worker_id) DO UPDATE SET ON CONFLICT(workspace_id, runtime_id, runtime_worker_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
runtime_worker_id = excluded.runtime_worker_id,
display_name = excluded.display_name, display_name = excluded.display_name,
profile = excluded.profile, profile = excluded.profile,
retention_state = CASE retention_state = CASE
@ -268,7 +272,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
updated_at = excluded.updated_at"#, updated_at = excluded.updated_at"#,
params![ params![
record.workspace_id, record.workspace_id,
record.worker_id,
record.runtime_id, record.runtime_id,
record.runtime_worker_id, record.runtime_worker_id,
record.display_name, record.display_name,
@ -287,26 +290,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
} }
fn get_worker_registry( fn get_worker_registry(
&self,
workspace_id: &str,
worker_id: &str,
) -> Result<Option<WorkerRegistryRecord>> {
self.with_conn(|conn| {
conn.query_row(
worker_registry_select_sql("WHERE workspace_id = ?1 AND worker_id = ?2").as_str(),
params![workspace_id, worker_id],
read_worker_registry_record,
)
.optional()
.map_err(Error::from)
})
}
fn get_worker_registry_by_runtime(
&self, &self,
workspace_id: &str, workspace_id: &str,
runtime_id: &str, runtime_id: &str,
runtime_worker_id: &str, runtime_worker_id: u64,
) -> Result<Option<WorkerRegistryRecord>> { ) -> Result<Option<WorkerRegistryRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.query_row( conn.query_row(
@ -344,26 +331,38 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn update_worker_retention( fn update_worker_retention(
&self, &self,
workspace_id: &str, workspace_id: &str,
worker_id: &str, runtime_id: &str,
runtime_worker_id: u64,
retention_state: &str, retention_state: &str,
updated_at: &str, updated_at: &str,
) -> Result<bool> { ) -> Result<bool> {
self.with_conn(|conn| { self.with_conn(|conn| {
let changed = conn.execute( let changed = conn.execute(
r#"UPDATE worker_registry r#"UPDATE worker_registry
SET retention_state = ?3, updated_at = ?4 SET retention_state = ?4, updated_at = ?5
WHERE workspace_id = ?1 AND worker_id = ?2"#, WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3"#,
params![workspace_id, worker_id, retention_state, updated_at], params![
workspace_id,
runtime_id,
runtime_worker_id,
retention_state,
updated_at
],
)?; )?;
Ok(changed > 0) Ok(changed > 0)
}) })
} }
fn delete_worker_registry(&self, workspace_id: &str, worker_id: &str) -> Result<bool> { fn delete_worker_registry(
&self,
workspace_id: &str,
runtime_id: &str,
runtime_worker_id: u64,
) -> Result<bool> {
self.with_conn(|conn| { self.with_conn(|conn| {
let changed = conn.execute( let changed = conn.execute(
"DELETE FROM worker_registry WHERE workspace_id = ?1 AND worker_id = ?2", "DELETE FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3",
params![workspace_id, worker_id], params![workspace_id, runtime_id, runtime_worker_id],
)?; )?;
Ok(changed > 0) Ok(changed > 0)
}) })
@ -468,14 +467,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
r#"INSERT INTO worker_workdir_links ( r#"INSERT INTO worker_workdir_links (
workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET ON CONFLICT(workspace_id, runtime_id, runtime_worker_id, workdir_id, role) DO UPDATE SET
linked_at = excluded.linked_at, linked_at = excluded.linked_at,
unlinked_at = excluded.unlinked_at"#, unlinked_at = excluded.unlinked_at"#,
params![ params![
record.workspace_id, record.workspace_id,
record.worker_id, record.runtime_id,
record.runtime_worker_id,
record.workdir_id, record.workdir_id,
record.role, record.role,
record.linked_at, record.linked_at,
@ -489,17 +489,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn list_worker_workdir_links( fn list_worker_workdir_links(
&self, &self,
workspace_id: &str, workspace_id: &str,
worker_id: &str, runtime_id: &str,
runtime_worker_id: u64,
) -> Result<Vec<WorkerWorkdirLinkRecord>> { ) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
FROM worker_workdir_links FROM worker_workdir_links
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#, ORDER BY linked_at DESC"#,
)?; )?;
let rows = stmt.query_map( let rows = stmt.query_map(
params![workspace_id, worker_id], params![workspace_id, runtime_id, runtime_worker_id],
read_worker_workdir_link_record, read_worker_workdir_link_record,
)?; )?;
rows.collect::<std::result::Result<Vec<_>, _>>() rows.collect::<std::result::Result<Vec<_>, _>>()
@ -514,7 +515,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> { ) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at r#"SELECT workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
FROM worker_workdir_links FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#, ORDER BY linked_at DESC"#,
@ -534,17 +535,18 @@ fn read_worker_workdir_link_record(
) -> rusqlite::Result<WorkerWorkdirLinkRecord> { ) -> rusqlite::Result<WorkerWorkdirLinkRecord> {
Ok(WorkerWorkdirLinkRecord { Ok(WorkerWorkdirLinkRecord {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
worker_id: row.get(1)?, runtime_id: row.get(1)?,
workdir_id: row.get(2)?, runtime_worker_id: row.get(2)?,
role: row.get(3)?, workdir_id: row.get(3)?,
linked_at: row.get(4)?, role: row.get(4)?,
unlinked_at: row.get(5)?, linked_at: row.get(5)?,
unlinked_at: row.get(6)?,
}) })
} }
fn worker_registry_select_sql(where_clause: &str) -> String { fn worker_registry_select_sql(where_clause: &str) -> String {
format!( format!(
"SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \ "SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \ retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
created_at, updated_at FROM worker_registry {where_clause}" created_at, updated_at FROM worker_registry {where_clause}"
) )
@ -553,18 +555,17 @@ fn worker_registry_select_sql(where_clause: &str) -> String {
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> { fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
Ok(WorkerRegistryRecord { Ok(WorkerRegistryRecord {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
worker_id: row.get(1)?, runtime_id: row.get(1)?,
runtime_id: row.get(2)?, runtime_worker_id: row.get(2)?,
runtime_worker_id: row.get(3)?, display_name: row.get(3)?,
display_name: row.get(4)?, profile: row.get(4)?,
profile: row.get(5)?, retention_state: row.get(5)?,
retention_state: row.get(6)?, transcript_ref: row.get(6)?,
transcript_ref: row.get(7)?, session_ref: row.get(7)?,
session_ref: row.get(8)?, summary_ref: row.get(8)?,
summary_ref: row.get(9)?, diagnostics_ref: row.get(9)?,
diagnostics_ref: row.get(10)?, created_at: row.get(10)?,
created_at: row.get(11)?, updated_at: row.get(11)?,
updated_at: row.get(12)?,
}) })
} }
@ -599,9 +600,8 @@ fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> {
r#" r#"
CREATE TABLE IF NOT EXISTS worker_registry ( CREATE TABLE IF NOT EXISTS worker_registry (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
runtime_worker_id TEXT NOT NULL, runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
profile TEXT, profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
@ -611,8 +611,7 @@ CREATE TABLE IF NOT EXISTS worker_registry (
diagnostics_ref TEXT, diagnostics_ref TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, worker_id), PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
); );
@ -634,13 +633,14 @@ CREATE TABLE IF NOT EXISTS workdir_registry (
CREATE TABLE IF NOT EXISTS worker_workdir_links ( CREATE TABLE IF NOT EXISTS worker_workdir_links (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
workdir_id TEXT NOT NULL, workdir_id TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
linked_at TEXT NOT NULL, linked_at TEXT NOT NULL,
unlinked_at TEXT, unlinked_at TEXT,
PRIMARY KEY (workspace_id, worker_id, workdir_id, role), PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role),
FOREIGN KEY (workspace_id, worker_id) REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
); );
@ -649,7 +649,7 @@ CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
ON workdir_registry(workspace_id, updated_at DESC); ON workdir_registry(workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC); ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC);
"#, "#,
)?; )?;
Ok(()) Ok(())
@ -802,7 +802,7 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
worker_id TEXT NOT NULL, worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
runtime_worker_id TEXT NOT NULL, runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
profile TEXT, profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
@ -835,6 +835,98 @@ fn remove_worker_registry_legacy_live_state_column(conn: &Connection) -> Result<
Ok(()) Ok(())
} }
fn use_composite_worker_registry_keys(conn: &Connection) -> Result<()> {
if !table_exists(conn, "worker_registry")?
|| !table_columns(conn, "worker_registry")?
.iter()
.any(|column| column == "worker_id")
{
return Ok(());
}
conn.execute_batch(
r#"
CREATE TABLE worker_registry_v5 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
display_name TEXT NOT NULL,
profile TEXT,
retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')),
transcript_ref TEXT,
session_ref TEXT,
summary_ref TEXT,
diagnostics_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO worker_registry_v5 (
workspace_id, runtime_id, runtime_worker_id, display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref,
created_at, updated_at
)
SELECT
workspace_id,
runtime_id,
CASE
WHEN typeof(runtime_worker_id) = 'integer' THEN runtime_worker_id
WHEN runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(runtime_worker_id, 8) AS INTEGER)
WHEN runtime_worker_id GLOB '[0-9]*' THEN CAST(runtime_worker_id AS INTEGER)
ELSE rowid
END,
display_name, profile,
retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref,
created_at, updated_at
FROM worker_registry;
CREATE TABLE worker_workdir_links_v5 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
runtime_worker_id INTEGER NOT NULL,
workdir_id TEXT NOT NULL,
role TEXT NOT NULL,
linked_at TEXT NOT NULL,
unlinked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id, runtime_worker_id, workdir_id, role),
FOREIGN KEY (workspace_id, runtime_id, runtime_worker_id) REFERENCES worker_registry_v5(workspace_id, runtime_id, runtime_worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
);
INSERT OR REPLACE INTO worker_workdir_links_v5 (
workspace_id, runtime_id, runtime_worker_id, workdir_id, role, linked_at, unlinked_at
)
SELECT
links.workspace_id,
registry.runtime_id,
CASE
WHEN typeof(registry.runtime_worker_id) = 'integer' THEN registry.runtime_worker_id
WHEN registry.runtime_worker_id GLOB 'worker-[0-9]*' THEN CAST(substr(registry.runtime_worker_id, 8) AS INTEGER)
WHEN registry.runtime_worker_id GLOB '[0-9]*' THEN CAST(registry.runtime_worker_id AS INTEGER)
ELSE registry.rowid
END,
links.workdir_id,
links.role,
links.linked_at,
links.unlinked_at
FROM worker_workdir_links AS links
JOIN worker_registry AS registry
ON registry.workspace_id = links.workspace_id
AND registry.worker_id = links.worker_id;
DROP TABLE worker_workdir_links;
DROP TABLE worker_registry;
ALTER TABLE worker_registry_v5 RENAME TO worker_registry;
ALTER TABLE worker_workdir_links_v5 RENAME TO worker_workdir_links;
CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
ON worker_registry(workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
ON worker_workdir_links(workspace_id, runtime_id, runtime_worker_id, linked_at DESC);
"#,
)?;
Ok(())
}
fn create_schema_v0_tables(conn: &Connection) -> Result<()> { fn create_schema_v0_tables(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@ -1058,7 +1150,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 4); assert_eq!(store.schema_version().await.unwrap(), 5);
let record = WorkspaceRecord { let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
@ -1070,7 +1162,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 4); assert_eq!(reopened.schema_version().await.unwrap(), 5);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@ -1190,7 +1282,6 @@ mod tests {
"worker_registry", "worker_registry",
[ [
"workspace_id", "workspace_id",
"worker_id",
"runtime_id", "runtime_id",
"runtime_worker_id", "runtime_worker_id",
"display_name", "display_name",
@ -1280,7 +1371,7 @@ mod tests {
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 4); assert_eq!(store.schema_version().await.unwrap(), 5);
store store
.with_conn(|conn| { .with_conn(|conn| {
@ -1381,13 +1472,12 @@ mod tests {
let worker = WorkerRegistryRecord { let worker = WorkerRegistryRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
worker_id: "embedded/browser-1".to_string(),
runtime_id: "embedded".to_string(), runtime_id: "embedded".to_string(),
runtime_worker_id: "browser-1".to_string(), runtime_worker_id: 1,
display_name: "Browser 1".to_string(), display_name: "Browser 1".to_string(),
profile: Some("builtin:companion".to_string()), profile: Some("builtin:companion".to_string()),
retention_state: "pinned".to_string(), retention_state: "pinned".to_string(),
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()), transcript_ref: Some("runtime://embedded/workers/00000001/transcript".to_string()),
session_ref: None, session_ref: None,
summary_ref: None, summary_ref: None,
diagnostics_ref: None, diagnostics_ref: None,
@ -1404,7 +1494,7 @@ mod tests {
let workdir = WorkdirRegistryRecord { let workdir = WorkdirRegistryRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
workdir_id: "backend-2-repo".to_string(), workdir_id: "0000019a00000000001".to_string(),
runtime_id: "embedded".to_string(), runtime_id: "embedded".to_string(),
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
selector: Some("develop".to_string()), selector: Some("develop".to_string()),
@ -1433,7 +1523,8 @@ mod tests {
let link = WorkerWorkdirLinkRecord { let link = WorkerWorkdirLinkRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
worker_id: worker.worker_id.clone(), runtime_id: worker.runtime_id.clone(),
runtime_worker_id: worker.runtime_worker_id.clone(),
workdir_id: workdir.workdir_id.clone(), workdir_id: workdir.workdir_id.clone(),
role: "primary_cwd".to_string(), role: "primary_cwd".to_string(),
linked_at: "4".to_string(), linked_at: "4".to_string(),
@ -1443,13 +1534,13 @@ mod tests {
assert_eq!( assert_eq!(
store store
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1") .get_worker_registry("local-dev", "embedded", 1)
.unwrap(), .unwrap(),
Some(expected_worker.clone()) Some(expected_worker.clone())
); );
assert_eq!( assert_eq!(
store store
.get_workdir_registry("local-dev", "backend-2-repo") .get_workdir_registry("local-dev", "0000019a00000000001")
.unwrap(), .unwrap(),
Some(workdir.clone()) Some(workdir.clone())
); );
@ -1465,7 +1556,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
store store
.list_worker_workdir_links("local-dev", "embedded/browser-1") .list_worker_workdir_links("local-dev", "embedded", 1)
.unwrap(), .unwrap(),
vec![link] vec![link]
); );