feat: add backend worker workdir registry
This commit is contained in:
parent
86f652a72f
commit
3e5546ea33
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Add Backend Worker/Workdir registry and link model'
|
title: 'Add Backend Worker/Workdir registry and link model'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-07-10T15:53:02Z'
|
created_at: '2026-07-10T15:53:02Z'
|
||||||
updated_at: '2026-07-10T16:12:07Z'
|
updated_at: '2026-07-10T16:38:10Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-10T16:10:57Z'
|
queued_at: '2026-07-10T16:10:57Z'
|
||||||
|
|
|
||||||
|
|
@ -143,4 +143,30 @@ Acceptance basis:
|
||||||
- Ticket scope is Backend Worker/Workdir registry/link authority; actual prune/delete/recovery automation and raw Runtime path persistence are explicitly out of scope。
|
- Ticket scope is Backend Worker/Workdir registry/link authority; actual prune/delete/recovery automation and raw Runtime path persistence are explicitly out of scope。
|
||||||
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-10T16:38:10Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Backend SQLite Worker/Workdir registries and Worker-Workdir link authority.
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- Added schema v3 tables for worker_registry, workdir_registry, and worker_workdir_links with explicit retention_state including pinned.
|
||||||
|
- Added store records/APIs plus round-trip tests for pinned Worker retention, removed Workdir preservation, and links.
|
||||||
|
- Added Backend-managed Workdir id assignment and pending registry write before Runtime materialization.
|
||||||
|
- Updated Workspace API to serve Browser Workdir lists from backend-managed registry rows, preserve canonical Workdir records after cleanup/removal, and distinguish runtime_unmanaged records when Runtime-direct Workdirs are encountered.
|
||||||
|
- Registered created/listed Workers and linked Workers to selected Workdirs without storing raw Runtime materialized paths.
|
||||||
|
- Extended worker-runtime WorkingDirectoryRequest with an optional backend_workdir_id used by local worktree materialization.
|
||||||
|
|
||||||
|
Validation run:
|
||||||
|
- git diff --check
|
||||||
|
- cargo test -p yoi-workspace-server --lib
|
||||||
|
- cargo test -p worker-runtime --features ws-server,fs-store
|
||||||
|
- cargo check -p yoi
|
||||||
|
- cd web/workspace && deno task check && deno task test
|
||||||
|
- yoi ticket doctor
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ pub struct WorkingDirectoryRequest {
|
||||||
pub materializer: MaterializerKind,
|
pub materializer: MaterializerKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dirty_state_policy: DirtyStatePolicy,
|
pub dirty_state_policy: DirtyStatePolicy,
|
||||||
|
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
|
||||||
|
/// Backend can create canonical registry rows before materialization.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backend_workdir_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1036,6 +1036,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,10 @@ impl WorkingDirectoryMaterializer for LocalGitWorktreeMaterializer {
|
||||||
&self,
|
&self,
|
||||||
request: &WorkingDirectoryRequest,
|
request: &WorkingDirectoryRequest,
|
||||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||||
let working_directory_id = next_working_directory_id(&request.repository.id);
|
let working_directory_id = request
|
||||||
|
.backend_workdir_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| next_working_directory_id(&request.repository.id));
|
||||||
self.materialize_with_working_directory_id(
|
self.materialize_with_working_directory_id(
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
request,
|
request,
|
||||||
|
|
@ -719,6 +722,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,16 @@ use crate::repositories::{
|
||||||
RepositoryRegistryReader, RepositorySummary,
|
RepositoryRegistryReader, RepositorySummary,
|
||||||
};
|
};
|
||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
use crate::store::{
|
||||||
|
ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord,
|
||||||
|
WorkspaceRecord,
|
||||||
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use worker_runtime::catalog::{
|
use worker_runtime::catalog::{
|
||||||
ConfigBundleRef, DirtyStatePolicy, MaterializerKind, ProfileSelector,
|
ConfigBundleRef, DirtyStatePolicy, MaterializerKind, ProfileSelector,
|
||||||
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim,
|
RepositorySelector as RuntimeRepositorySelector, WorkingDirectoryClaim,
|
||||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectorySummary,
|
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryStatusKind,
|
||||||
|
WorkingDirectorySummary,
|
||||||
};
|
};
|
||||||
use worker_runtime::config_bundle::ConfigBundle;
|
use worker_runtime::config_bundle::ConfigBundle;
|
||||||
use worker_runtime::http_server::{
|
use worker_runtime::http_server::{
|
||||||
|
|
@ -1274,12 +1278,36 @@ fn create_working_directory_for_runtime(
|
||||||
request: BrowserWorkingDirectoryCreateRequest,
|
request: BrowserWorkingDirectoryCreateRequest,
|
||||||
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> {
|
||||||
let runtime_id = request.runtime_id.clone();
|
let runtime_id = request.runtime_id.clone();
|
||||||
let working_directory_request = working_directory_request_for_browser(&api, request)?;
|
let mut working_directory_request = working_directory_request_for_browser(&api, request)?;
|
||||||
|
let workdir_id = next_backend_workdir_id(&working_directory_request.repository.id);
|
||||||
|
working_directory_request.backend_workdir_id = Some(workdir_id.clone());
|
||||||
|
let pending = WorkdirRegistryRecord {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
workdir_id: workdir_id.clone(),
|
||||||
|
runtime_id: runtime_id.clone(),
|
||||||
|
repository_id: working_directory_request.repository.id.clone(),
|
||||||
|
selector: working_directory_request
|
||||||
|
.repository
|
||||||
|
.selector
|
||||||
|
.as_ref()
|
||||||
|
.map(|selector| selector.as_ref().to_string()),
|
||||||
|
resolved_commit: None,
|
||||||
|
materialization_status: "pending".to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
management_kind: "backend_managed".to_string(),
|
||||||
|
created_at: now_registry_timestamp(),
|
||||||
|
updated_at: now_registry_timestamp(),
|
||||||
|
};
|
||||||
|
api.store.upsert_workdir_registry(&pending)?;
|
||||||
let result = api
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.create_working_directory(&runtime_id, working_directory_request)
|
.create_working_directory(&runtime_id, working_directory_request)
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
let Some(working_directory) = result.working_directory else {
|
let Some(working_directory) = result.working_directory else {
|
||||||
|
let mut failed = pending;
|
||||||
|
failed.materialization_status = "failed".to_string();
|
||||||
|
failed.updated_at = now_registry_timestamp();
|
||||||
|
api.store.upsert_workdir_registry(&failed)?;
|
||||||
return Err(ApiError::with_diagnostics(
|
return Err(ApiError::with_diagnostics(
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id,
|
runtime_id,
|
||||||
|
|
@ -1289,6 +1317,13 @@ fn create_working_directory_for_runtime(
|
||||||
result.diagnostics,
|
result.diagnostics,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let record = workdir_record_from_summary(
|
||||||
|
&api,
|
||||||
|
&runtime_id,
|
||||||
|
&working_directory.summary,
|
||||||
|
"backend_managed",
|
||||||
|
);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
item: working_directory.summary,
|
item: working_directory.summary,
|
||||||
|
|
@ -1305,21 +1340,43 @@ fn working_directory_detail_for_runtime(
|
||||||
.runtime
|
.runtime
|
||||||
.working_directory(runtime_id, working_directory_id)
|
.working_directory(runtime_id, working_directory_id)
|
||||||
.map_err(|err| err.into_error())?;
|
.map_err(|err| err.into_error())?;
|
||||||
let Some(working_directory) = result.working_directory else {
|
if let Some(working_directory) = result.working_directory {
|
||||||
return Err(ApiError::with_diagnostics(
|
let management_kind = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
||||||
|
.map(|record| record.management_kind)
|
||||||
|
.unwrap_or_else(|| "runtime_unmanaged".to_string());
|
||||||
|
let record = workdir_record_from_summary(
|
||||||
|
&api,
|
||||||
|
runtime_id,
|
||||||
|
&working_directory.summary,
|
||||||
|
management_kind.as_str(),
|
||||||
|
);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
item: working_directory.summary,
|
||||||
|
diagnostics: result.diagnostics,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(record) = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
||||||
|
{
|
||||||
|
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
item: workdir_summary_from_record(&record),
|
||||||
|
diagnostics: result.diagnostics,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(ApiError::with_diagnostics(
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
code: "workspace_working_directory_lookup_failed".to_string(),
|
code: "workspace_working_directory_lookup_failed".to_string(),
|
||||||
message: "Runtime did not return working directory".to_string(),
|
message: "Runtime did not return working directory".to_string(),
|
||||||
},
|
},
|
||||||
result.diagnostics,
|
result.diagnostics,
|
||||||
));
|
))
|
||||||
};
|
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
|
||||||
item: working_directory.summary,
|
|
||||||
diagnostics: result.diagnostics,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_working_directory_for_runtime(
|
fn cleanup_working_directory_for_runtime(
|
||||||
|
|
@ -1341,6 +1398,18 @@ fn cleanup_working_directory_for_runtime(
|
||||||
result.diagnostics,
|
result.diagnostics,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
let management_kind = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, working_directory_id)?
|
||||||
|
.map(|record| record.management_kind)
|
||||||
|
.unwrap_or_else(|| "runtime_unmanaged".to_string());
|
||||||
|
let record = workdir_record_from_summary(
|
||||||
|
&api,
|
||||||
|
runtime_id,
|
||||||
|
&working_directory.summary,
|
||||||
|
management_kind.as_str(),
|
||||||
|
);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
item: working_directory.summary,
|
item: working_directory.summary,
|
||||||
|
|
@ -1966,6 +2035,7 @@ fn working_directory_request_from_repository(
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2026,6 +2096,10 @@ async fn create_workspace_worker(
|
||||||
content: initial_text,
|
content: initial_text,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
let selected_working_directory_id = request
|
||||||
|
.working_directory
|
||||||
|
.as_ref()
|
||||||
|
.map(|selection| selection.working_directory_id.clone());
|
||||||
let resolved_working_directory =
|
let resolved_working_directory =
|
||||||
request
|
request
|
||||||
.working_directory
|
.working_directory
|
||||||
|
|
@ -2039,7 +2113,7 @@ async fn create_workspace_worker(
|
||||||
.spawn_worker(
|
.spawn_worker(
|
||||||
&request.runtime_id,
|
&request.runtime_id,
|
||||||
WorkerSpawnRequest {
|
WorkerSpawnRequest {
|
||||||
requested_worker_name: Some(display_name),
|
requested_worker_name: Some(display_name.clone()),
|
||||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||||
expected_segments: if initial_input.is_some() { 1 } else { 0 },
|
expected_segments: if initial_input.is_some() { 1 } else { 0 },
|
||||||
|
|
@ -2064,6 +2138,38 @@ async fn create_workspace_worker(
|
||||||
code: "workspace_worker_create_missing_summary".to_string(),
|
code: "workspace_worker_create_missing_summary".to_string(),
|
||||||
message: "Runtime completed worker creation without returning a Worker summary".to_string(),
|
message: "Runtime completed worker creation without returning a Worker summary".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
let worker_record =
|
||||||
|
record_worker_summary(&api, &worker, &display_name, Some(request.profile.clone()))?;
|
||||||
|
if let Some(workdir_id) = selected_working_directory_id.as_deref() {
|
||||||
|
if api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, workdir_id)?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
if let Ok(result) = api
|
||||||
|
.runtime
|
||||||
|
.working_directory(worker.runtime_id.as_str(), workdir_id)
|
||||||
|
.map_err(|err| err.into_error())
|
||||||
|
{
|
||||||
|
if let Some(status) = result.working_directory {
|
||||||
|
let record = workdir_record_from_summary(
|
||||||
|
&api,
|
||||||
|
worker.runtime_id.as_str(),
|
||||||
|
&status.summary,
|
||||||
|
"runtime_unmanaged",
|
||||||
|
);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(&api.config.workspace_id, workdir_id)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
link_worker_to_workdir(&api, &worker_record, workdir_id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let runtime_id = worker.runtime_id.clone();
|
let runtime_id = worker.runtime_id.clone();
|
||||||
let worker_id = worker.worker_id.clone();
|
let worker_id = worker.worker_id.clone();
|
||||||
let workspace_id = api.workspace_id().to_string();
|
let workspace_id = api.workspace_id().to_string();
|
||||||
|
|
@ -2423,11 +2529,19 @@ async fn list_host_workers(
|
||||||
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
|
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
|
||||||
let limit = api.config.max_records.min(200);
|
let limit = api.config.max_records.min(200);
|
||||||
let runtime_workers = api.runtime.list_workers(limit);
|
let runtime_workers = api.runtime.list_workers(limit);
|
||||||
|
for worker in &runtime_workers.items {
|
||||||
|
let _ = record_worker_summary(
|
||||||
|
&api,
|
||||||
|
worker,
|
||||||
|
worker.worker_id.as_str(),
|
||||||
|
worker.profile.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(RuntimeListResponse {
|
Ok(RuntimeListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
items: runtime_workers.items,
|
items: runtime_workers.items,
|
||||||
source: "worker_runtime_registry".to_string(),
|
source: "backend_worker_registry_synced".to_string(),
|
||||||
diagnostics: runtime_workers.diagnostics,
|
diagnostics: runtime_workers.diagnostics,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -3126,25 +3240,152 @@ fn working_directory_repository_options(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirectorySummary>> {
|
fn working_directory_summaries(api: &WorkspaceApi) -> ApiResult<Vec<WorkingDirectorySummary>> {
|
||||||
let list = api
|
let records = api
|
||||||
.runtime
|
.store
|
||||||
.list_working_directories(EMBEDDED_WORKER_RUNTIME_ID)
|
.list_managed_workdir_registry(&api.config.workspace_id, 200)?;
|
||||||
.map_err(|err| err.into_error())?;
|
Ok(records
|
||||||
if !list.diagnostics.is_empty() {
|
.iter()
|
||||||
return Err(ApiError::with_diagnostics(
|
.map(workdir_summary_from_record)
|
||||||
Error::RuntimeOperationFailed {
|
.collect::<Vec<_>>())
|
||||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
}
|
||||||
code: "workspace_working_directory_list_failed".to_string(),
|
|
||||||
message: "Runtime did not list working directories".to_string(),
|
fn backend_worker_id(runtime_id: &str, runtime_worker_id: &str) -> String {
|
||||||
},
|
format!("{runtime_id}/{runtime_worker_id}")
|
||||||
list.diagnostics,
|
}
|
||||||
));
|
|
||||||
|
fn now_registry_timestamp() -> String {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis().to_string())
|
||||||
|
.unwrap_or_else(|_| "0".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_safe_id_component(value: &str) -> String {
|
||||||
|
let sanitized: String = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
}
|
}
|
||||||
Ok(list
|
})
|
||||||
.items
|
.collect();
|
||||||
.into_iter()
|
sanitized.trim_matches('-').chars().take(48).collect()
|
||||||
.map(|status| status.summary)
|
}
|
||||||
.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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_worker_summary(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &WorkerSummary,
|
||||||
|
display_name: &str,
|
||||||
|
profile: Option<String>,
|
||||||
|
) -> ApiResult<WorkerRegistryRecord> {
|
||||||
|
let timestamp = now_registry_timestamp();
|
||||||
|
let worker_id = backend_worker_id(worker.runtime_id.as_str(), worker.worker_id.as_str());
|
||||||
|
let record = WorkerRegistryRecord {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
worker_id,
|
||||||
|
runtime_id: worker.runtime_id.as_str().to_string(),
|
||||||
|
runtime_worker_id: worker.worker_id.as_str().to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
profile,
|
||||||
|
lifecycle_state: worker.status.clone(),
|
||||||
|
retention_state: "normal".to_string(),
|
||||||
|
transcript_ref: Some(format!(
|
||||||
|
"runtime://{}/workers/{}/transcript",
|
||||||
|
worker.runtime_id.as_str(),
|
||||||
|
worker.worker_id.as_str()
|
||||||
|
)),
|
||||||
|
session_ref: None,
|
||||||
|
summary_ref: None,
|
||||||
|
diagnostics_ref: None,
|
||||||
|
created_at: timestamp.clone(),
|
||||||
|
updated_at: timestamp,
|
||||||
|
};
|
||||||
|
api.store.upsert_worker_registry(&record)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_record_from_summary(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
summary: &WorkingDirectorySummary,
|
||||||
|
management_kind: &str,
|
||||||
|
) -> WorkdirRegistryRecord {
|
||||||
|
let timestamp = now_registry_timestamp();
|
||||||
|
WorkdirRegistryRecord {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
workdir_id: summary.working_directory_id.clone(),
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
repository_id: summary.repository_id.clone(),
|
||||||
|
selector: summary.requested_selector.clone(),
|
||||||
|
resolved_commit: summary.resolved_commit.clone(),
|
||||||
|
materialization_status: match summary.status {
|
||||||
|
WorkingDirectoryStatusKind::Active => "present",
|
||||||
|
WorkingDirectoryStatusKind::Removed => "removed",
|
||||||
|
WorkingDirectoryStatusKind::CleanupPending => "pending",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
management_kind: management_kind.to_string(),
|
||||||
|
created_at: timestamp.clone(),
|
||||||
|
updated_at: timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_summary_from_record(record: &WorkdirRegistryRecord) -> WorkingDirectorySummary {
|
||||||
|
let status = match record.materialization_status.as_str() {
|
||||||
|
"present" => WorkingDirectoryStatusKind::Active,
|
||||||
|
"pending" => WorkingDirectoryStatusKind::CleanupPending,
|
||||||
|
_ => WorkingDirectoryStatusKind::Removed,
|
||||||
|
};
|
||||||
|
WorkingDirectorySummary {
|
||||||
|
working_directory_id: record.workdir_id.clone(),
|
||||||
|
repository_id: record.repository_id.clone(),
|
||||||
|
requested_selector: record.selector.clone(),
|
||||||
|
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||||
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
resolved_commit: record.resolved_commit.clone(),
|
||||||
|
resolved_tree: None,
|
||||||
|
cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget {
|
||||||
|
kind: "local_git_worktree".to_string(),
|
||||||
|
working_directory_id: record.workdir_id.clone(),
|
||||||
|
repository_id: record.repository_id.clone(),
|
||||||
|
}),
|
||||||
|
cleanup_policy: Some("manual_or_worker_stop".to_string()),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_worker_to_workdir(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker_record: &WorkerRegistryRecord,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
let timestamp = now_registry_timestamp();
|
||||||
|
api.store
|
||||||
|
.upsert_worker_workdir_link(&WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
worker_id: worker_record.worker_id.clone(),
|
||||||
|
workdir_id: workdir_id.to_string(),
|
||||||
|
role: "primary_cwd".to_string(),
|
||||||
|
linked_at: timestamp,
|
||||||
|
unlinked_at: None,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_working_directory_claim_for_browser(
|
fn validate_working_directory_claim_for_browser(
|
||||||
|
|
@ -3207,6 +3448,7 @@ fn working_directory_request_for_browser(
|
||||||
},
|
},
|
||||||
materializer: MaterializerKind::LocalGitWorktree,
|
materializer: MaterializerKind::LocalGitWorktree,
|
||||||
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
dirty_state_policy: DirtyStatePolicy::CleanPointOnly,
|
||||||
|
backend_workdir_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "align legacy workspace bootstrap with schema v0",
|
name: "align legacy workspace bootstrap with schema v0",
|
||||||
apply: align_legacy_bootstrap_schema,
|
apply: align_legacy_bootstrap_schema,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 3,
|
||||||
|
name: "backend worker workdir registry schema",
|
||||||
|
apply: create_worker_workdir_registry_tables,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
|
|
@ -44,11 +49,94 @@ pub struct WorkspaceRecord {
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerRegistryRecord {
|
||||||
|
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_worker_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
pub lifecycle_state: String,
|
||||||
|
/// Retention state is explicit so `pinned` can be represented before prune exists.
|
||||||
|
pub retention_state: String,
|
||||||
|
pub transcript_ref: Option<String>,
|
||||||
|
pub session_ref: Option<String>,
|
||||||
|
pub summary_ref: Option<String>,
|
||||||
|
pub diagnostics_ref: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkdirRegistryRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub workdir_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub selector: Option<String>,
|
||||||
|
pub resolved_commit: Option<String>,
|
||||||
|
pub materialization_status: String,
|
||||||
|
pub cleanliness: String,
|
||||||
|
/// `backend_managed` rows are authored by this Backend; `runtime_unmanaged` is for diagnostics only.
|
||||||
|
pub management_kind: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkerWorkdirLinkRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub workdir_id: String,
|
||||||
|
pub role: String,
|
||||||
|
pub linked_at: String,
|
||||||
|
pub unlinked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ControlPlaneStore: Send + Sync {
|
pub trait ControlPlaneStore: Send + Sync {
|
||||||
async fn schema_version(&self) -> Result<i64>;
|
async fn schema_version(&self) -> Result<i64>;
|
||||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>;
|
||||||
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>>;
|
||||||
|
|
||||||
|
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()>;
|
||||||
|
fn get_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
|
fn get_worker_registry_by_runtime(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>>;
|
||||||
|
fn list_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkerRegistryRecord>>;
|
||||||
|
|
||||||
|
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>;
|
||||||
|
fn get_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkdirRegistryRecord>>;
|
||||||
|
fn list_managed_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>>;
|
||||||
|
|
||||||
|
fn upsert_worker_workdir_link(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
|
||||||
|
fn list_worker_workdir_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -131,6 +219,332 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upsert_worker_registry(&self, record: &WorkerRegistryRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO worker_registry (
|
||||||
|
workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile,
|
||||||
|
lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref,
|
||||||
|
diagnostics_ref, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||||
|
ON CONFLICT(workspace_id, worker_id) DO UPDATE SET
|
||||||
|
runtime_id = excluded.runtime_id,
|
||||||
|
runtime_worker_id = excluded.runtime_worker_id,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
profile = excluded.profile,
|
||||||
|
lifecycle_state = excluded.lifecycle_state,
|
||||||
|
retention_state = excluded.retention_state,
|
||||||
|
transcript_ref = excluded.transcript_ref,
|
||||||
|
session_ref = excluded.session_ref,
|
||||||
|
summary_ref = excluded.summary_ref,
|
||||||
|
diagnostics_ref = excluded.diagnostics_ref,
|
||||||
|
updated_at = excluded.updated_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.worker_id,
|
||||||
|
record.runtime_id,
|
||||||
|
record.runtime_worker_id,
|
||||||
|
record.display_name,
|
||||||
|
record.profile,
|
||||||
|
record.lifecycle_state,
|
||||||
|
record.retention_state,
|
||||||
|
record.transcript_ref,
|
||||||
|
record.session_ref,
|
||||||
|
record.summary_ref,
|
||||||
|
record.diagnostics_ref,
|
||||||
|
record.created_at,
|
||||||
|
record.updated_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
workspace_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
runtime_worker_id: &str,
|
||||||
|
) -> Result<Option<WorkerRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
worker_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 AND runtime_id = ?2 AND runtime_worker_id = ?3",
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
params![workspace_id, runtime_id, runtime_worker_id],
|
||||||
|
read_worker_registry_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_worker_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkerRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = worker_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 ORDER BY updated_at DESC LIMIT ?2",
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(sql.as_str())?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![workspace_id, limit as i64],
|
||||||
|
read_worker_registry_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO workdir_registry (
|
||||||
|
workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit,
|
||||||
|
materialization_status, cleanliness, management_kind, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||||
|
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
|
||||||
|
runtime_id = excluded.runtime_id,
|
||||||
|
repository_id = excluded.repository_id,
|
||||||
|
selector = excluded.selector,
|
||||||
|
resolved_commit = excluded.resolved_commit,
|
||||||
|
materialization_status = excluded.materialization_status,
|
||||||
|
cleanliness = excluded.cleanliness,
|
||||||
|
management_kind = excluded.management_kind,
|
||||||
|
updated_at = excluded.updated_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.workdir_id,
|
||||||
|
record.runtime_id,
|
||||||
|
record.repository_id,
|
||||||
|
record.selector,
|
||||||
|
record.resolved_commit,
|
||||||
|
record.materialization_status,
|
||||||
|
record.cleanliness,
|
||||||
|
record.management_kind,
|
||||||
|
record.created_at,
|
||||||
|
record.updated_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkdirRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
workdir_registry_select_sql("WHERE workspace_id = ?1 AND workdir_id = ?2").as_str(),
|
||||||
|
params![workspace_id, workdir_id],
|
||||||
|
read_workdir_registry_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_managed_workdir_registry(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<WorkdirRegistryRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let sql = workdir_registry_select_sql(
|
||||||
|
"WHERE workspace_id = ?1 AND management_kind = 'backend_managed' ORDER BY updated_at DESC LIMIT ?2",
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(sql.as_str())?;
|
||||||
|
let rows = stmt.query_map(params![workspace_id, limit as i64], read_workdir_registry_record)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_worker_workdir_link(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO worker_workdir_links (
|
||||||
|
workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
|
||||||
|
linked_at = excluded.linked_at,
|
||||||
|
unlinked_at = excluded.unlinked_at"#,
|
||||||
|
params![
|
||||||
|
record.workspace_id,
|
||||||
|
record.worker_id,
|
||||||
|
record.workdir_id,
|
||||||
|
record.role,
|
||||||
|
record.linked_at,
|
||||||
|
record.unlinked_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_worker_workdir_links(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, worker_id, workdir_id, role, linked_at, unlinked_at
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND worker_id = ?2 AND unlinked_at IS NULL
|
||||||
|
ORDER BY linked_at DESC"#,
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![workspace_id, worker_id], |row| {
|
||||||
|
Ok(WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
worker_id: row.get(1)?,
|
||||||
|
workdir_id: row.get(2)?,
|
||||||
|
role: row.get(3)?,
|
||||||
|
linked_at: row.get(4)?,
|
||||||
|
unlinked_at: row.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_registry_select_sql(where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT workspace_id, worker_id, runtime_id, runtime_worker_id, display_name, profile, \
|
||||||
|
lifecycle_state, retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, \
|
||||||
|
created_at, updated_at FROM worker_registry {where_clause}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_worker_registry_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkerRegistryRecord> {
|
||||||
|
Ok(WorkerRegistryRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
worker_id: row.get(1)?,
|
||||||
|
runtime_id: row.get(2)?,
|
||||||
|
runtime_worker_id: row.get(3)?,
|
||||||
|
display_name: row.get(4)?,
|
||||||
|
profile: row.get(5)?,
|
||||||
|
lifecycle_state: row.get(6)?,
|
||||||
|
retention_state: row.get(7)?,
|
||||||
|
transcript_ref: row.get(8)?,
|
||||||
|
session_ref: row.get(9)?,
|
||||||
|
summary_ref: row.get(10)?,
|
||||||
|
diagnostics_ref: row.get(11)?,
|
||||||
|
created_at: row.get(12)?,
|
||||||
|
updated_at: row.get(13)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_registry_select_sql(where_clause: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT workspace_id, workdir_id, runtime_id, repository_id, selector, resolved_commit, \
|
||||||
|
materialization_status, cleanliness, management_kind, created_at, updated_at \
|
||||||
|
FROM workdir_registry {where_clause}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_workdir_registry_record(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<WorkdirRegistryRecord> {
|
||||||
|
Ok(WorkdirRegistryRecord {
|
||||||
|
workspace_id: row.get(0)?,
|
||||||
|
workdir_id: row.get(1)?,
|
||||||
|
runtime_id: row.get(2)?,
|
||||||
|
repository_id: row.get(3)?,
|
||||||
|
selector: row.get(4)?,
|
||||||
|
resolved_commit: row.get(5)?,
|
||||||
|
materialization_status: row.get(6)?,
|
||||||
|
cleanliness: row.get(7)?,
|
||||||
|
management_kind: row.get(8)?,
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
updated_at: row.get(10)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_worker_workdir_registry_tables(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_registry (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
runtime_worker_id TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
profile TEXT,
|
||||||
|
lifecycle_state TEXT NOT NULL,
|
||||||
|
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, worker_id),
|
||||||
|
UNIQUE (workspace_id, runtime_id, runtime_worker_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workdir_registry (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
workdir_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
repository_id TEXT NOT NULL,
|
||||||
|
selector TEXT,
|
||||||
|
resolved_commit TEXT,
|
||||||
|
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'missing', 'removed', 'failed')),
|
||||||
|
cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')),
|
||||||
|
management_kind TEXT NOT NULL CHECK (management_kind IN ('backend_managed', 'runtime_unmanaged')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, workdir_id),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_workdir_links (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
workdir_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
linked_at TEXT NOT NULL,
|
||||||
|
unlinked_at TEXT,
|
||||||
|
PRIMARY KEY (workspace_id, 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, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_registry_workspace_updated
|
||||||
|
ON worker_registry(workspace_id, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workdir_registry_workspace_updated
|
||||||
|
ON workdir_registry(workspace_id, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
|
||||||
|
ON worker_workdir_links(workspace_id, worker_id, linked_at DESC);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configure_sqlite(conn: &Connection) -> Result<()> {
|
fn configure_sqlite(conn: &Connection) -> Result<()> {
|
||||||
|
|
@ -488,7 +902,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(), 2);
|
assert_eq!(store.schema_version().await.unwrap(), 3);
|
||||||
|
|
||||||
let record = WorkspaceRecord {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
|
|
@ -500,7 +914,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(), 2);
|
assert_eq!(reopened.schema_version().await.unwrap(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
|
|
@ -527,6 +941,9 @@ mod tests {
|
||||||
"ticket_worker_links",
|
"ticket_worker_links",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"audit_events",
|
"audit_events",
|
||||||
|
"worker_registry",
|
||||||
|
"workdir_registry",
|
||||||
|
"worker_workdir_links",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
tables.contains(expected),
|
tables.contains(expected),
|
||||||
|
|
@ -688,7 +1105,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(), 2);
|
assert_eq!(store.schema_version().await.unwrap(), 3);
|
||||||
|
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
|
|
@ -773,6 +1190,89 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let db = temp.path().join("workspace.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
let workspace = WorkspaceRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
display_name: "Local Dev".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: "1".to_string(),
|
||||||
|
updated_at: "1".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_workspace(&workspace).await.unwrap();
|
||||||
|
|
||||||
|
let worker = WorkerRegistryRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
worker_id: "embedded/browser-1".to_string(),
|
||||||
|
runtime_id: "embedded".to_string(),
|
||||||
|
runtime_worker_id: "browser-1".to_string(),
|
||||||
|
display_name: "Browser 1".to_string(),
|
||||||
|
profile: Some("builtin:companion".to_string()),
|
||||||
|
lifecycle_state: "idle".to_string(),
|
||||||
|
retention_state: "pinned".to_string(),
|
||||||
|
transcript_ref: Some("runtime://embedded/workers/browser-1/transcript".to_string()),
|
||||||
|
session_ref: None,
|
||||||
|
summary_ref: None,
|
||||||
|
diagnostics_ref: None,
|
||||||
|
created_at: "2".to_string(),
|
||||||
|
updated_at: "2".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_worker_registry(&worker).unwrap();
|
||||||
|
|
||||||
|
let workdir = WorkdirRegistryRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
workdir_id: "backend-2-repo".to_string(),
|
||||||
|
runtime_id: "embedded".to_string(),
|
||||||
|
repository_id: "repo".to_string(),
|
||||||
|
selector: Some("develop".to_string()),
|
||||||
|
resolved_commit: Some("abcdef".to_string()),
|
||||||
|
materialization_status: "removed".to_string(),
|
||||||
|
cleanliness: "clean".to_string(),
|
||||||
|
management_kind: "backend_managed".to_string(),
|
||||||
|
created_at: "2".to_string(),
|
||||||
|
updated_at: "3".to_string(),
|
||||||
|
};
|
||||||
|
store.upsert_workdir_registry(&workdir).unwrap();
|
||||||
|
|
||||||
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: "local-dev".to_string(),
|
||||||
|
worker_id: worker.worker_id.clone(),
|
||||||
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
|
role: "primary_cwd".to_string(),
|
||||||
|
linked_at: "4".to_string(),
|
||||||
|
unlinked_at: None,
|
||||||
|
};
|
||||||
|
store.upsert_worker_workdir_link(&link).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_worker_registry_by_runtime("local-dev", "embedded", "browser-1")
|
||||||
|
.unwrap(),
|
||||||
|
Some(worker.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workdir_registry("local-dev", "backend-2-repo")
|
||||||
|
.unwrap(),
|
||||||
|
Some(workdir.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_managed_workdir_registry("local-dev", 10)
|
||||||
|
.unwrap(),
|
||||||
|
vec![workdir]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links("local-dev", "embedded/browser-1")
|
||||||
|
.unwrap(),
|
||||||
|
vec![link]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn table_names(conn: &Connection) -> BTreeSet<String> {
|
fn table_names(conn: &Connection) -> BTreeSet<String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user