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:
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1078
-258
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user