feat: merge durable Workdir updates from develop

# Conflicts:
#	docs/README.md
#	docs/design/durable-operations.md
#	web/workspace/deno.json
This commit is contained in:
2026-09-03 02:47:50 +09:00
43 changed files with 6467 additions and 524 deletions
+22
View File
@@ -1438,6 +1438,28 @@ mod tests {
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
}
#[test]
fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
let feature = resolved.manifest.feature;
assert!(feature.worker.enabled);
assert!(!feature.worker.direct_spawn);
assert!(feature.manage_workdir.enabled);
assert!(feature.merge_request.show);
assert!(feature.merge_request.readiness_check);
assert!(feature.merge_request.complete);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
}
#[test]
fn profile_resolution_requires_runtime_worker_name() {
let tmp = TempDir::new().unwrap();
@@ -23,8 +23,9 @@ use workdir::{
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
WorkingDirectoryRemovalRequest as WorkdirRemovalRequest,
WorkingDirectoryRemovalResponse as WorkdirRemovalResponse,
};
use crate::feature::{
@@ -51,7 +52,7 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
const DELETE_DESCRIPTION: &str = "Delete one persistent Workdir by id through Backend Workspace API authority. Occupied, blocked, or dirty Workdirs requiring confirmation are rejected.";
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
#[derive(Clone, Debug)]
pub struct ManageWorkdirFeature {
@@ -484,12 +485,21 @@ impl WorkspaceHttpWorkdirBackend {
)?;
let workspace_id = encode_path_segment(self.workspace_id()?);
let workdir_path = encode_path_segment(workdir_id);
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
body: None,
})?;
workdir_output(format!("Deleted Workdir {workdir_id}"), &response)
let response = self.execute_json::<WorkdirRemovalResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Delete,
format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
serde_json::to_string(&WorkdirRemovalRequest {
reason: validate_delete_reason(&input.reason)?.to_string(),
})
.map_err(decode_error)?,
))?;
workdir_output(
format!(
"Workdir {workdir_id} removal disposition: {:?}",
response.disposition
),
&response,
)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
@@ -611,6 +621,17 @@ fn validate_identity<'a>(
Ok(value)
}
fn validate_delete_reason(reason: &str) -> Result<&str, ToolError> {
let reason = reason.trim();
if reason.is_empty() || reason.len() > 500 || reason.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(
"WorkdirDelete reason must be non-empty, contain no control characters, and be at most 500 bytes"
.to_string(),
));
}
Ok(reason)
}
fn validate_optional_selector(selector: Option<String>) -> Result<Option<String>, ToolError> {
let Some(selector) = selector else {
return Ok(None);
@@ -689,9 +710,10 @@ fn delete_schema() -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["working_directory_id"],
"required": ["working_directory_id", "reason"],
"properties": {
"working_directory_id": {"type": "string", "minLength": 1}
"working_directory_id": {"type": "string", "minLength": 1},
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
}
})
}
@@ -736,6 +758,7 @@ struct WorkdirAttachmentResponse {
#[serde(deny_unknown_fields)]
struct WorkdirDeleteInput {
working_directory_id: String,
reason: String,
}
#[cfg(test)]
@@ -959,7 +982,10 @@ mod tests {
assert!(create["properties"].get("session_id").is_none());
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
assert!(attach_schema()["properties"].get("session_id").is_none());
assert_eq!(delete_schema()["required"], json!(["working_directory_id"]));
assert_eq!(
delete_schema()["required"],
json!(["working_directory_id", "reason"])
);
}
#[test]
@@ -999,15 +1025,9 @@ mod tests {
"attached": false
})),
response(json!({
"workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": {
"working_directory_id": "wd-created",
"repository_id": "main",
"materializer_kind": "local_git_worktree",
"status": "not_found"
},
"diagnostics": []
"working_directory_id": "wd-created",
"disposition": "removed",
"retryable": false
})),
]));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
@@ -1052,6 +1072,7 @@ mod tests {
backend
.delete(WorkdirDeleteInput {
working_directory_id: "wd-created".to_string(),
reason: "remove stale Workdir".to_string(),
})
.unwrap();
@@ -1087,6 +1108,9 @@ mod tests {
"/api/w/workspace%2Ftest/working-directories/wd-created"
);
assert_eq!(requests[4].method, WorkspaceRequestMethod::Delete);
let body: serde_json::Value =
serde_json::from_str(requests[4].body.as_deref().unwrap()).unwrap();
assert_eq!(body, json!({"reason": "remove stale Workdir"}));
}
#[tokio::test]
+18
View File
@@ -920,4 +920,22 @@ mod tests {
);
}
}
#[test]
fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() {
let rendered = PromptCatalog::builtins_only()
.unwrap()
.render_name("role.orchestrator", Value::UNDEFINED)
.unwrap();
assert!(rendered.contains("This policy governs naming only"));
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
assert!(rendered.contains("call `WorkerStop`"));
assert!(rendered.contains("call `WorkerRemove`"));
assert!(rendered.contains("only then call `WorkdirDelete`"));
assert!(rendered.contains("`CurrentAssignment` means unassign and reread"));
assert!(!rendered.contains("{% include"));
}
}
+40
View File
@@ -9,6 +9,7 @@ use agen::llm_client::{ClientError, LlmClient, Request};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use futures::{Stream, StreamExt};
use manifest::{ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry};
use workdir::{
@@ -248,6 +249,14 @@ async fn make_worker_with_pwd_manifest_and_workspace_context(
workspace_context: WorkerWorkspaceContext,
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
let manifest = WorkerManifest::from_toml(manifest_toml).unwrap();
make_worker_with_manifest_and_workspace_context(client, manifest, workspace_context).await
}
async fn make_worker_with_manifest_and_workspace_context(
client: MockClient,
manifest: WorkerManifest,
workspace_context: WorkerWorkspaceContext,
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
let store_tmp = tempfile::tempdir().unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
@@ -783,6 +792,37 @@ permission = "write"
}
}
#[tokio::test]
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
let workspace = tempfile::tempdir().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
let workspace_context =
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
resolved.manifest,
workspace_context,
)
.await;
let handle = spawn_controller(worker).await;
handle.send(Method::run_text("Hello")).await.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
let request = wait_for_captured_request(&client_for_assert).await;
let installed = request_tool_names(&request);
assert!(installed.iter().any(|name| name == "WorkerRemove"));
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
}
#[tokio::test]
async fn worker_and_sub_worker_features_install_one_canonical_control_surface() {
let manifest = r#"
+214
View File
@@ -222,6 +222,100 @@ pub struct WorkspaceResponse {
pub extension_points: WorkspaceExtensionPoints,
}
/// Workspace identity metadata exposed by the current settings resource.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetadataSettingsResponse {
pub workspace_id: String,
pub display_name: String,
pub created_at: String,
pub revision: String,
pub source: String,
pub diagnostics: Vec<Diagnostic>,
}
/// Compare-and-swap update for Workspace identity display metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct UpdateWorkspaceMetadataRequest {
pub display_name: String,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetadataMutationResponse {
pub workspace: WorkspaceMetadataSettingsResponse,
pub diagnostics: Vec<Diagnostic>,
}
/// Read-only Profile catalog projected from one active Workspace config revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct ProfileSettingsResponse {
pub workspace_id: String,
pub registry_revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
pub config_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceProfileSummary {
pub profile_id: String,
pub selector: String,
pub label: String,
pub source_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_source_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub editable: bool,
pub is_default: bool,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String,
pub display_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub size_bytes: u64,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -397,6 +491,34 @@ pub struct WorkingDirectoryCleanupTarget {
pub repository_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryRemovalRequest {
pub reason: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryRemovalDisposition {
Removed,
Retained,
AttentionRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryRemovalResponse {
pub working_directory_id: String,
pub disposition: WorkingDirectoryRemovalDisposition,
pub retryable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_category: Option<String>,
}
/// Durable Workspace occupancy projection for one Workdir.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -1096,6 +1218,13 @@ pub fn catalog_typescript() -> String {
WorkspaceExtensionPointState::decl(&config),
WorkspaceExtensionPoints::decl(&config),
WorkspaceResponse::decl(&config),
WorkspaceMetadataSettingsResponse::decl(&config),
UpdateWorkspaceMetadataRequest::decl(&config),
WorkspaceMetadataMutationResponse::decl(&config),
ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config),
WorkspaceProfileSourceProvenance::decl(&config),
RepositorySourceKind::decl(&config),
RepositorySource::decl(&config),
RepositoryObservedStatus::decl(&config),
@@ -1294,6 +1423,14 @@ mod tests {
assert!(output.contains("export type RepositoryListResponse ="));
assert!(output.contains("items: Array<RepositorySummary>"));
assert!(output.contains("observed_at?: string | null"));
assert!(output.contains("export type WorkspaceMetadataSettingsResponse ="));
assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
assert!(output.contains("export type ProfileSettingsResponse ="));
assert!(output.contains("config_revision?: number | null"));
assert!(output.contains("provenance: WorkspaceProfileSourceProvenance"));
assert!(output.contains(
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
));
assert!(!output.contains("repository_id: string, display_name"));
}
@@ -1326,6 +1463,83 @@ mod tests {
assert_eq!(decoded, value);
}
#[test]
fn workspace_metadata_and_profile_projection_fixtures_round_trip() {
let diagnostic = Diagnostic {
code: "profile_projection_warning".to_string(),
severity: DiagnosticSeverity::Warning,
message: "projected from the active config revision".to_string(),
};
let metadata = WorkspaceMetadataSettingsResponse {
workspace_id: "workspace-test".to_string(),
display_name: "Test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
revision: "sha256:metadata".to_string(),
source: "workspace-config".to_string(),
diagnostics: vec![diagnostic.clone()],
};
round_trip(metadata.clone());
round_trip(UpdateWorkspaceMetadataRequest {
display_name: "Renamed".to_string(),
revision: metadata.revision.clone(),
});
round_trip(WorkspaceMetadataMutationResponse {
workspace: metadata,
diagnostics: vec![],
});
round_trip(ProfileSettingsResponse {
workspace_id: "workspace-test".to_string(),
registry_revision: "config-source:7:sha256:tree:sha256:projection".to_string(),
config_revision: Some(7),
tree_digest: Some("sha256:tree".to_string()),
projection_digest: Some("sha256:projection".to_string()),
default_profile: Some("workspace:coder".to_string()),
profiles: vec![WorkspaceProfileSummary {
profile_id: "workspace:coder".to_string(),
selector: "workspace:coder".to_string(),
label: "Coder".to_string(),
source_kind: "project".to_string(),
profile_source_id: Some("profile-source-1".to_string()),
description: None,
editable: true,
is_default: true,
diagnostics: vec![diagnostic.clone()],
}],
sources: vec![WorkspaceProfileSourceSummary {
profile_source_id: "profile-source-1".to_string(),
display_path: "profiles/coder.dcdl".to_string(),
kind: "profile".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: "sha256:source".to_string(),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: "config-source:7".to_string(),
size_bytes: 128,
diagnostics: vec![],
}],
diagnostics: vec![diagnostic],
});
let absent_optional_fields = serde_json::json!({
"workspace_id": "workspace-test",
"registry_revision": "builtin",
"profiles": [],
"sources": [],
"diagnostics": []
});
let decoded: ProfileSettingsResponse =
serde_json::from_value(absent_optional_fields.clone()).unwrap();
assert_eq!(decoded.config_revision, None);
assert_eq!(decoded.tree_digest, None);
assert_eq!(decoded.projection_digest, None);
assert_eq!(decoded.default_profile, None);
assert_eq!(
serde_json::to_value(decoded).unwrap(),
absent_optional_fields
);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker {
+1
View File
@@ -30,6 +30,7 @@ pub mod server;
pub mod skills;
pub mod store;
pub mod workdir_create_operations;
mod workdir_removal;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_subscription;
@@ -12,11 +12,15 @@ use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use workspace_api::{
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
};
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
@@ -427,81 +431,6 @@ fn build_virtual_profile_archive(
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceMetadataSettingsResponse {
pub workspace_id: String,
pub display_name: String,
pub created_at: String,
pub revision: String,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateWorkspaceMetadataRequest {
pub display_name: String,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceMetadataMutationResponse {
pub workspace: WorkspaceMetadataSettingsResponse,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSettingsResponse {
pub workspace_id: String,
pub registry_revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSummary {
pub profile_id: String,
pub selector: String,
pub label: String,
pub source_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_source_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub editable: bool,
pub is_default: bool,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String,
pub display_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
@@ -804,8 +733,8 @@ fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> RuntimeDiagnostic {
RuntimeDiagnostic {
) -> Diagnostic {
Diagnostic {
code: code.into(),
severity,
message: message.into(),
File diff suppressed because it is too large Load Diff
+154 -22
View File
@@ -267,6 +267,11 @@ const MIGRATIONS: &[Migration] = &[
name: "require one account owner for every Workspace",
apply: require_workspace_account_owner,
},
Migration {
version: 49,
name: "create durable Workdir removal operations",
apply: crate::workdir_removal::create_workdir_removal_operations,
},
];
struct Migration {
@@ -4884,6 +4889,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"Workdir {workdir_id} is not registered in Workspace {workspace_id}"
)));
}
let removal_pending: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM workdir_removal_operations
WHERE workspace_id = ?1 AND workdir_id = ?2 AND state = 'pending'
)"#,
params![workspace_id, workdir_id],
|row| row.get(0),
)?;
if removal_pending {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir {workdir_id} has a pending durable removal operation"
)));
}
let occupied: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM worker_workdir_links
@@ -10085,6 +10103,120 @@ mod tests {
.unwrap();
}
#[test]
fn schema_v49_upgrades_persisted_v48_workdir_fixture() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server-v48.db");
{
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations_through(&conn, 48).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert!(!table_exists(&conn, "workdir_removal_operations").unwrap());
conn.execute_batch(
r#"
INSERT INTO accounts (
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces (
workspace_id, owner_account_id, display_name, state, created_at, updated_at
) VALUES ('workspace-a', 'owner-account', 'Workspace A', 'active', '1', '1');
INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (
'workspace-a', 'repository-a', 'Repository A', 'git', 'local', '/repo-a',
'local_path', '/repo-a', 'develop', 1,
'sha256:source-a', 'unverified', NULL, '1', '1'
);
INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree,
observed_at_epoch_seconds, materialization_status, cleanliness,
created_at, updated_at
) VALUES (
'workspace-a', 'workdir-a', 'runtime-a', 'repository-a',
'refs/heads/develop', 'abc', 'tree-a',
'refs/heads/work', 'def', 'tree-b',
1, 'present', 'clean', '1', '1'
);
"#,
)
.unwrap();
}
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 49);
assert!(table_exists(conn, "workdir_removal_operations")?);
let columns = table_columns(conn, "workdir_removal_operations")?;
for required in [
"workspace_id",
"operation_id",
"request_fingerprint",
"workdir_id",
"runtime_id",
"repository_id",
"materialization_fingerprint",
"source_actor",
"reason",
"state",
"attempt_count",
"retryable",
"disposition",
"failure_category",
"attempt_owner_pid",
"attempt_owner_start_marker",
"created_at",
"updated_at",
"completed_at",
] {
assert!(
columns.iter().any(|column| column == required),
"missing {required}"
);
}
let preserved: (String, String, String) = conn.query_row(
"SELECT workspace_id, repository_id, materialization_status FROM workdir_registry WHERE workdir_id='workdir-a'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(
preserved,
(
"workspace-a".to_string(),
"repository-a".to_string(),
"present".to_string(),
)
);
let foreign_key_failures: i64 = conn.query_row(
"SELECT count(*) FROM pragma_foreign_key_check",
[],
|row| row.get(0),
)?;
assert_eq!(foreign_key_failures, 0);
Ok(())
})
.unwrap();
let workdir = store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.unwrap();
let intent = crate::workdir_removal::workdir_removal_intent(
&workdir,
"migration-test",
"remove migrated Workdir",
)
.unwrap();
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
assert_eq!(operation.workspace_id, "workspace-a");
assert_eq!(operation.working_directory_id, "workdir-a");
}
#[test]
fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() {
let conn = Connection::open_in_memory().unwrap();
@@ -10118,7 +10250,7 @@ mod tests {
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let remote = conn
.query_row(
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
@@ -10197,7 +10329,7 @@ mod tests {
let before = std::fs::read(&path).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 36);
assert_eq!(plan.target_schema_version, 48);
assert_eq!(plan.target_schema_version, 49);
assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
@@ -10211,7 +10343,7 @@ mod tests {
store
.with_conn(|conn| {
assert!(table_exists(conn, "worker_diagnostics_archives")?);
assert_eq!(current_schema_version(conn)?, 48);
assert_eq!(current_schema_version(conn)?, 49);
Ok(())
})
.unwrap();
@@ -10351,7 +10483,7 @@ mod tests {
),
]
);
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let foreign_key_error: Option<String> = conn
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
.optional()
@@ -10481,7 +10613,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let controller_worker_id: String = conn
.query_row(
@@ -10599,7 +10731,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
}
@@ -10618,7 +10750,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let settings = conn
.query_row(
"SELECT settings_revision, language FROM workspace_memory_settings \
@@ -10659,7 +10791,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -10727,7 +10859,7 @@ INSERT INTO worker_workdir_attachment_reservations (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let repositories_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -10910,7 +11042,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
assert!(
!store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -10927,7 +11059,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 48);
assert_eq!(reopened.schema_version().await.unwrap(), 49);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@@ -11693,7 +11825,7 @@ INSERT INTO worker_registry (
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
migrated
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 48);
assert_eq!(current_schema_version(conn)?, 49);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
1,
@@ -12044,7 +12176,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 44);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "workdir_create_operations").unwrap());
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
@@ -12071,7 +12203,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 45);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
for table in [
"repository_ssh_credentials",
"repository_ssh_credential_revisions",
@@ -12098,7 +12230,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 46);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
"source_kind",
@@ -12132,13 +12264,13 @@ INSERT INTO worker_registry (
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'future')",
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'future')",
[],
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 49 is newer"), "{error}");
assert!(error.contains("schema version 50 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}");
}
@@ -12362,7 +12494,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&mut conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let workspace_id: Option<String> = conn
.query_row(
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
@@ -12991,7 +13123,7 @@ WHERE workspace_id = 'workspace-a'
assign_explicit_test_workspace_owner(&conn);
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
store
.with_conn(|conn| {
@@ -13180,7 +13312,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(),
@@ -13258,7 +13390,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(),
@@ -13671,7 +13803,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),
@@ -1,4 +1,4 @@
use rusqlite::{OptionalExtension, params};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
@@ -103,6 +103,65 @@ impl SqliteWorkspaceStore {
})
}
pub fn begin_failed_workdir_create_retry(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before retry"
))
})?;
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if operation.state != "failed" {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir create operation `{operation_id}` is not a failed retry"
)));
}
let removal_pending: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending')",
params![workspace_id, operation.working_directory_id],
|row| row.get(0),
)?;
if removal_pending {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir {} has a pending durable removal operation",
operation.working_directory_id
)));
}
let changed = tx.execute(
r#"UPDATE workdir_create_operations
SET state='pending', failure=NULL, updated_at=?1
WHERE workspace_id=?2 AND operation_id=?3
AND request_fingerprint=?4 AND state='failed'"#,
params![updated_at, workspace_id, operation_id, request_fingerprint],
)?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir create operation `{operation_id}` retry was claimed concurrently"
)));
}
let updated = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after retry claim"
))
})?;
tx.commit()?;
Ok(updated)
})
}
pub fn bind_workdir_create_repository_access(
&self,
workspace_id: &str,
@@ -406,11 +465,32 @@ mod tests {
.unwrap();
assert_eq!(replayed, bound);
assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
let failed = store
.finish_workdir_create_operation(
"workspace",
"call-1",
&record.request_fingerprint,
false,
Some("provider failed"),
"2026-08-24T00:00:03Z",
)
.unwrap();
assert_eq!(failed.state, "failed");
let retry = store
.begin_failed_workdir_create_retry(
"workspace",
"call-1",
&record.request_fingerprint,
"2026-08-24T00:00:04Z",
)
.unwrap();
assert_eq!(retry.state, "pending");
assert_eq!(retry.failure, None);
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
.unwrap(),
Some(bound.clone())
Some(retry.clone())
);
let mut changed_input = record.clone();
changed_input.request_fingerprint =
File diff suppressed because it is too large Load Diff