diff --git a/.yoi/tickets/00001KXDJ1V3D/item.md b/.yoi/tickets/00001KXDJ1V3D/item.md index 876c082b..8cc21060 100644 --- a/.yoi/tickets/00001KXDJ1V3D/item.md +++ b/.yoi/tickets/00001KXDJ1V3D/item.md @@ -1,8 +1,8 @@ --- title: 'Preserve typed Workdir not-found errors' -state: 'inprogress' +state: 'done' created_at: '2026-07-13T10:58:32Z' -updated_at: '2026-07-13T10:59:02Z' +updated_at: '2026-07-13T11:16:48Z' assignee: null queued_by: 'yoi ticket' queued_at: '2026-07-13T10:59:02Z' diff --git a/.yoi/tickets/00001KXDJ1V3D/thread.md b/.yoi/tickets/00001KXDJ1V3D/thread.md index effe5963..ea1fe5ca 100644 --- a/.yoi/tickets/00001KXDJ1V3D/thread.md +++ b/.yoi/tickets/00001KXDJ1V3D/thread.md @@ -39,4 +39,37 @@ Ticket を `yoi ticket` が queued にしました。 State changed to `inprogress`. +--- + + + +## Implementation report + +Removed text/substring-based Workdir not-found handling. + +- Added `RuntimeError::WorkingDirectory(WorkingDirectoryDiagnostic)` so worker-runtime preserves typed Workdir diagnostic codes instead of flattening them into `invalid_request`. +- Worker Runtime REST errors now return Workdir diagnostic codes such as `working_directory_not_found` as `error.code` and map that code to HTTP 404. +- Remote Runtime HTTP error mapping now preserves typed error codes from Runtime error bodies instead of remapping all HTTP 404 responses to a generic worker-not-found code. +- Embedded Runtime diagnostics also preserve typed Workdir diagnostic codes. +- `workdir_status_from_runtime_miss` now uses exact `diagnostic.code == "working_directory_not_found"` rather than substring matching. +- Added tests for preserving Workdir REST error codes and for exact Workdir miss classification. + +Validation: +- `cargo fmt --check` +- `git diff --check` +- `cargo check -q` +- `cargo test -q -p worker-runtime --features fs-store,ws-server` +- `cargo test -q -p yoi-workspace-server` +- `nix build .#yoi --no-link` + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/crates/worker-runtime/src/error.rs b/crates/worker-runtime/src/error.rs index 108385c5..6392f2ce 100644 --- a/crates/worker-runtime/src/error.rs +++ b/crates/worker-runtime/src/error.rs @@ -1,5 +1,6 @@ use crate::execution::WorkerExecutionResult; use crate::identity::WorkerId; +use crate::working_directory::WorkingDirectoryDiagnostic; use std::path::PathBuf; /// Errors returned by the embedded Runtime API. @@ -38,6 +39,9 @@ pub enum RuntimeError { #[error("invalid request: {0}")] InvalidRequest(String), + #[error(transparent)] + WorkingDirectory(#[from] WorkingDirectoryDiagnostic), + #[error("config bundle `{bundle_id}` was not found")] ConfigBundleMissing { bundle_id: String }, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index aaec3382..c543ac08 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -761,15 +761,15 @@ async fn require_local_token( #[derive(Debug)] struct RuntimeHttpRestError { status: StatusCode, - code: &'static str, + code: String, message: String, } impl RuntimeHttpRestError { - fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + fn new(status: StatusCode, code: impl Into, message: impl Into) -> Self { Self { status, - code, + code: code.into(), message: message.into(), } } @@ -801,7 +801,7 @@ impl IntoResponse for RuntimeHttpRestError { fn into_response(self) -> Response { let body = RuntimeHttpErrorResponse { error: RuntimeHttpErrorDetail { - code: self.code.to_string(), + code: self.code, message: self.message, }, }; @@ -814,6 +814,11 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => { StatusCode::NOT_FOUND } + RuntimeError::WorkingDirectory(diagnostic) + if diagnostic.code == "working_directory_not_found" => + { + StatusCode::NOT_FOUND + } RuntimeError::RuntimeStopped | RuntimeError::WorkerExecutionUnavailable { .. } | RuntimeError::ExecutionBackendUnavailable { .. } @@ -823,7 +828,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { | RuntimeError::InvalidInitialInputKind { .. } | RuntimeError::ConfigBundleDigestMismatch { .. } | RuntimeError::InvalidProfileSelector { .. } - | RuntimeError::UnsupportedConfigDeclaration { .. } => StatusCode::BAD_REQUEST, + | RuntimeError::UnsupportedConfigDeclaration { .. } + | RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST, RuntimeError::StoreIo { .. } | RuntimeError::StoreMissing { .. } | RuntimeError::StoreCorrupt { .. } @@ -831,24 +837,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { } } -fn code_for_runtime_error(error: &RuntimeError) -> &'static str { +fn code_for_runtime_error(error: &RuntimeError) -> String { match error { - RuntimeError::RuntimeStopped => "runtime_stopped", - RuntimeError::WorkerNotFound { .. } => "worker_not_found", - RuntimeError::WorkerExecutionUnavailable { .. } => "worker_execution_unavailable", - RuntimeError::ExecutionBackendUnavailable { .. } => "execution_backend_unavailable", - RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected", - RuntimeError::LimitTooLarge { .. } => "limit_too_large", - RuntimeError::InvalidRequest(_) => "invalid_request", - RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind", - RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing", - RuntimeError::ConfigBundleDigestMismatch { .. } => "config_bundle_digest_mismatch", - RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector", - RuntimeError::UnsupportedConfigDeclaration { .. } => "unsupported_config_declaration", - RuntimeError::StoreIo { .. } => "store_io", - RuntimeError::StoreMissing { .. } => "store_missing", - RuntimeError::StoreCorrupt { .. } => "store_corrupt", - RuntimeError::StatePoisoned => "state_poisoned", + RuntimeError::RuntimeStopped => "runtime_stopped".to_string(), + RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(), + RuntimeError::WorkerExecutionUnavailable { .. } => { + "worker_execution_unavailable".to_string() + } + RuntimeError::ExecutionBackendUnavailable { .. } => { + "execution_backend_unavailable".to_string() + } + RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(), + RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(), + RuntimeError::InvalidRequest(_) => "invalid_request".to_string(), + RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(), + RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(), + RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing".to_string(), + RuntimeError::ConfigBundleDigestMismatch { .. } => { + "config_bundle_digest_mismatch".to_string() + } + RuntimeError::InvalidProfileSelector { .. } => "invalid_profile_selector".to_string(), + RuntimeError::UnsupportedConfigDeclaration { .. } => { + "unsupported_config_declaration".to_string() + } + RuntimeError::StoreIo { .. } => "store_io".to_string(), + RuntimeError::StoreMissing { .. } => "store_missing".to_string(), + RuntimeError::StoreCorrupt { .. } => "store_corrupt".to_string(), + RuntimeError::StatePoisoned => "state_poisoned".to_string(), } } @@ -1125,6 +1140,21 @@ mod tests { assert_eq!(error.error.code, "worker_not_found"); assert!(error.error.message.contains("999")); } + + #[test] + fn workdir_runtime_errors_preserve_diagnostic_code() { + let error = + RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic { + code: "working_directory_not_found".to_string(), + message: "working directory missing-workdir was not found".to_string(), + }); + + assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND); + assert_eq!( + code_for_runtime_error(&error), + "working_directory_not_found" + ); + } } #[cfg(all(test, feature = "ws-server"))] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 5963e9bb..f8e31dcd 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -233,7 +233,7 @@ impl Runtime { }; backend .create_working_directory(&request) - .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + .map_err(RuntimeError::from) } /// List Runtime-owned working directories through the attached execution backend. @@ -269,7 +269,7 @@ impl Runtime { }; let status = backend .working_directory(working_directory_id) - .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string()))?; + .map_err(RuntimeError::from)?; self.annotate_working_directory_status(status) } @@ -294,7 +294,7 @@ impl Runtime { }; backend .cleanup_working_directory(working_directory_id) - .map_err(|diagnostic| RuntimeError::InvalidRequest(diagnostic.to_string())) + .map_err(RuntimeError::from) } fn annotate_working_directory_statuses( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d0b8e691..12a60b57 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -2144,7 +2144,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { worker: Some(self.map_worker_detail(response.worker)), diagnostics: Vec::new(), }, - Err(diagnostic) if diagnostic.code == "remote_worker_not_found" => WorkerLookupResult { + Err(diagnostic) if diagnostic.code == "worker_not_found" => WorkerLookupResult { worker: None, diagnostics: Vec::new(), }, @@ -2860,6 +2860,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti DiagnosticSeverity::Warning, error.to_string(), ), + EmbeddedRuntimeError::WorkingDirectory(workdir_diagnostic) => diagnostic( + workdir_diagnostic.code.clone(), + DiagnosticSeverity::Warning, + workdir_diagnostic.message.clone(), + ), EmbeddedRuntimeError::InvalidRequest(_) | EmbeddedRuntimeError::ConfigBundleMissing { .. } | EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. } @@ -2989,7 +2994,8 @@ fn remote_http_status_diagnostic( StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { ("remote_runtime_auth_failed", DiagnosticSeverity::Error) } - StatusCode::NOT_FOUND => ("remote_worker_not_found", DiagnosticSeverity::Warning), + _ if error.is_some() => (remote_code, DiagnosticSeverity::Warning), + StatusCode::NOT_FOUND => ("remote_runtime_not_found", DiagnosticSeverity::Warning), StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => { ("remote_runtime_unsupported", DiagnosticSeverity::Warning) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e9d4d60a..3afc3b3c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4290,7 +4290,7 @@ fn sync_runtime_workdir_observations( fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str { if diagnostics .iter() - .any(|diagnostic| diagnostic.code.contains("not_found")) + .any(|diagnostic| diagnostic.code == "working_directory_not_found") { "not_found" } else { @@ -5211,6 +5211,26 @@ mod tests { ); } + #[test] + fn workdir_runtime_miss_uses_exact_typed_code() { + assert_eq!( + workdir_status_from_runtime_miss(&[RuntimeDiagnostic { + code: "working_directory_not_found".to_string(), + severity: DiagnosticSeverity::Warning, + message: "missing".to_string(), + }]), + "not_found" + ); + assert_eq!( + workdir_status_from_runtime_miss(&[RuntimeDiagnostic { + code: "some_other_not_found".to_string(), + severity: DiagnosticSeverity::Warning, + message: "not a typed workdir miss".to_string(), + }]), + "unknown" + ); + } + #[tokio::test] async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() { let dir = tempfile::tempdir().unwrap();