diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 047bb187b..8c28b63eb 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -252,6 +252,9 @@ impl_openapi_schema!( PluginArtifactDeliveryRequest, PluginArtifactDeliveryMetadata, PluginWorkerDiagnostics, + SubjectBodyApiError, + SubjectBodyFailedRequest, + SubjectBodyRequestResolution, SubjectBodyResponse, SubjectBodyStartRequest, SubjectBodyStopRequest, @@ -1564,7 +1567,7 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, #[body] request: SubjectBodyStartRequest, - ) -> Result; + ) -> Result; #[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] @@ -1574,7 +1577,7 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, #[body] request: SubjectBodyStopRequest, - ) -> Result; + ) -> Result; #[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop/outcome", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500, 502], bearer_auth = true, browser_auth = true)] @@ -1584,7 +1587,7 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, #[body] request: SubjectBodyStopRequest, - ) -> Result; + ) -> Result; #[patch( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior", diff --git a/crates/server-api/src/subject_body.rs b/crates/server-api/src/subject_body.rs index 32f4b9561..da20e2f9c 100644 --- a/crates/server-api/src/subject_body.rs +++ b/crates/server-api/src/subject_body.rs @@ -1,9 +1,66 @@ //! Subject-facing projections and intents over ordinary Worker lifecycle authority. //! No current-body mapping or execution state is owned by these DTOs. -use crate::{Diagnostic, WorkerSummary}; +use crate::{Diagnostic, RepositoryApiError, WorkerSummary}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Resolution of this screen's intent, not a judgment about historical operation success. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum SubjectBodyRequestResolution { + NotAccepted, + Superseded, + Unresolved, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectBodyFailedRequest { + pub workspace_id: String, + pub subject_id: String, + pub request_id: String, + pub resolution: SubjectBodyRequestResolution, +} + +/// Only action handlers with a causally bound request may provide resolution evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectBodyApiError { + pub error: String, + pub message: String, + pub diagnostics: Vec, + pub request: Option, + #[serde(skip, default = "super::default_repository_error_status")] + pub status: u16, +} + +impl api_macros::HttpError for SubjectBodyApiError { + fn status_code(&self) -> u16 { + self.status + } +} + +impl api_macros::HttpRequestError for SubjectBodyApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self::from(RepositoryApiError::from_request_rejection(status, message)) + } +} + +impl From for SubjectBodyApiError { + fn from(error: RepositoryApiError) -> Self { + Self { + status: error.status, + error: error.error, + message: error.message, + diagnostics: error.diagnostics, + request: None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -112,6 +169,9 @@ pub fn subject_body_api_typescript() -> String { use ts_rs::TS; let config = ts_rs::Config::default(); let declarations = [ + SubjectBodyRequestResolution::decl(&config), + SubjectBodyFailedRequest::decl(&config), + SubjectBodyApiError::decl(&config), SubjectBodyRef::decl(&config), SubjectBodyObservation::decl(&config), SubjectBodyResponse::decl(&config), @@ -123,7 +183,7 @@ pub fn subject_body_api_typescript() -> String { SubjectBodyStopOutcomeResponse::decl(&config), ]; format!( - "// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.\n// Do not edit manually.\n\nimport type {{ Diagnostic, WorkerSummary }} from \"./worker-launch-api\";\n\n{}\n", + "// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.\n// Do not edit manually.\n\nimport type {{ Diagnostic, WorkerSummary }} from \"./worker-launch-api.ts\";\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -137,6 +197,41 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn action_error_wire_contract_preserves_status_without_unbound_terminal_evidence() { + use api_macros::{HttpError, HttpRequestError}; + let mut error = SubjectBodyApiError::from(RepositoryApiError::new( + 409, + "Conflict", + "old observation", + vec![], + )); + assert_eq!(error.status_code(), 409); + let unbound = serde_json::to_value(&error).unwrap(); + assert!(unbound["request"].is_null()); + assert!(unbound.get("status").is_none()); + error.request = Some(SubjectBodyFailedRequest { + workspace_id: "workspace".into(), + subject_id: "subject".into(), + request_id: "request".into(), + resolution: SubjectBodyRequestResolution::NotAccepted, + }); + let wire = serde_json::to_value(&error).unwrap(); + assert_eq!( + wire["request"], + json!({"workspace_id":"workspace", "subject_id":"subject", "request_id":"request", "resolution":"not_accepted"}) + ); + assert_eq!( + serde_json::from_value::(wire) + .unwrap() + .request, + error.request + ); + let rejected = SubjectBodyApiError::from_request_rejection(422, "invalid payload".into()); + assert_eq!(rejected.status_code(), 422); + assert!(rejected.request.is_none()); + } + #[test] fn start_requires_an_explicit_nullable_current_observation() { assert!( diff --git a/crates/worker-runtime/src/error.rs b/crates/worker-runtime/src/error.rs index f299d3eb8..433c6ec54 100644 --- a/crates/worker-runtime/src/error.rs +++ b/crates/worker-runtime/src/error.rs @@ -12,6 +12,9 @@ pub enum RuntimeError { #[error("initial worker input must be user input, got {kind}")] InvalidInitialInputKind { kind: String }, + #[error("worker {worker_id} Restore observation is stale; request was not accepted")] + RestoreObservationStale { worker_id: WorkerId }, + #[error("worker {worker_id} Restore observation conflicts with the current operation")] RestoreObservationConflict { worker_id: WorkerId }, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 90a54f04c..d591008a4 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2443,6 +2443,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { StatusCode::CONFLICT } RuntimeError::RuntimeStopped + | RuntimeError::RestoreObservationStale { .. } | RuntimeError::RestoreObservationConflict { .. } | RuntimeError::RestoreAdmissionBusy { .. } | RuntimeError::StopObservationConflict { .. } @@ -2472,6 +2473,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { fn code_for_runtime_error(error: &RuntimeError) -> String { match error { RuntimeError::RuntimeStopped => "runtime_stopped".to_string(), + RuntimeError::RestoreObservationStale { .. } => "restore_observation_stale".to_string(), RuntimeError::RestoreObservationConflict { .. } => { "restore_observation_conflict".to_string() } @@ -4013,14 +4015,15 @@ mod tests { request_id: "stale-http-restore".into(), preparation: None, }; - let response = - authed_json_request(app.clone(), Method::POST, &restore_uri, token, &stale).await; - assert_eq!(response.status(), StatusCode::CONFLICT); - let conflict: RuntimeHttpErrorResponse = read_json(response).await; - assert_eq!(conflict.error.code, "restore_observation_conflict"); + let coordinate_uri = format!("{restore_uri}/coordinate"); + for uri in [&restore_uri, &coordinate_uri] { + let response = authed_json_request(app.clone(), Method::POST, uri, token, &stale).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let conflict: RuntimeHttpErrorResponse = read_json(response).await; + assert_eq!(conflict.error.code, "restore_observation_stale"); + } let mut restore_request = runtime.test_restore_request(&created.worker.worker_ref); - let coordinate_uri = format!("{restore_uri}/coordinate"); let mut coordination = runtime_api::WorkerRestoreCoordinationRequest { expected_observation_token: restore_request.expected_observation_token.clone(), request_id: restore_request.request_id.clone(), @@ -4064,6 +4067,20 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let admitted: runtime_api::WorkerRestoreCoordinationResponse = read_json(response).await; assert!(admitted.result.is_none() && admitted.preparation.is_some()); + let admitted_observation = runtime.worker_detail(&created.worker.worker_ref).unwrap(); + let mut mismatched_owner = restore_request.clone(); + mismatched_owner.expected_observation_token = "different-owner-input".into(); + for uri in [&restore_uri, &coordinate_uri] { + let response = + authed_json_request(app.clone(), Method::POST, uri, token, &mismatched_owner).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let conflict: RuntimeHttpErrorResponse = read_json(response).await; + assert_eq!(conflict.error.code, "restore_observation_conflict"); + } + assert_eq!( + runtime.worker_detail(&created.worker.worker_ref).unwrap(), + admitted_observation + ); assert_eq!( runtime .worker_detail(&created.worker.worker_ref) diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 493ecdf78..32930beb4 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2246,7 +2246,12 @@ impl Runtime { if coordinate && request.preparation.is_none() && !recovering_pending { if candidate.restore_guard.observation_token != request.expected_observation_token - || candidate.restore_guard.active_request_id.is_some() + { + return Err(RuntimeError::RestoreObservationStale { + worker_id: worker_ref.worker_id, + }); + } + if candidate.restore_guard.active_request_id.is_some() || candidate.has_pending_lifecycle_operation() { return Err(conflict()); @@ -2264,8 +2269,12 @@ impl Runtime { )); } state.ensure_running()?; - if candidate.restore_guard.observation_token != request.expected_observation_token - || candidate.restore_guard.active_request_id.is_some() + if candidate.restore_guard.observation_token != request.expected_observation_token { + return Err(RuntimeError::RestoreObservationStale { + worker_id: worker_ref.worker_id, + }); + } + if candidate.restore_guard.active_request_id.is_some() || (candidate.has_pending_lifecycle_operation() && !recovering_pending) { return Err(conflict()); @@ -7960,6 +7969,7 @@ fn runtime_worker_create_failure_fields( error: &RuntimeError, ) -> (&'static str, Option, Option) { match error { + RuntimeError::RestoreObservationStale { .. } => ("restore_observation_stale", None, None), RuntimeError::RestoreObservationConflict { .. } => { ("restore_observation_conflict", None, None) } @@ -12280,7 +12290,7 @@ mod tests { stopping.join().unwrap().unwrap(); assert!(matches!( restoring.join().unwrap(), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!(*backend.stop_count.lock().unwrap(), 1); assert_eq!(*backend.restore_count.lock().unwrap(), 0); @@ -12572,6 +12582,49 @@ mod tests { .1 .is_some() ); + let admitted_observation = runtime.worker_detail(&worker.worker_ref).unwrap(); + for preparation in [None, Some(runtime_api::WorkerRestorePreparation::default())] { + for (request_id, token, expected_code) in [ + ( + "unseen-owner", + coordination.expected_observation_token.clone(), + "restore_observation_stale", + ), + ( + "unseen-owner", + admitted_observation + .restore_observation_token + .clone() + .unwrap(), + "restore_observation_conflict", + ), + ( + coordination.request_id.as_str(), + "different-owner-input".into(), + "restore_observation_conflict", + ), + ] { + let error = runtime + .coordinate_worker_restore_operation( + &worker.worker_ref, + runtime_api::WorkerRestoreCoordinationRequest { + expected_observation_token: token, + request_id: request_id.into(), + preparation: preparation.clone(), + }, + Some(&scope), + ) + .unwrap_err(); + assert_eq!( + runtime_worker_create_failure_fields(&error).0, + expected_code + ); + assert_eq!( + runtime.worker_detail(&worker.worker_ref).unwrap(), + admitted_observation + ); + } + } let operation_id = runtime .lock() .unwrap() @@ -12706,7 +12759,7 @@ mod tests { let before = runtime.worker_detail(&worker.worker_ref).unwrap(); assert!(matches!( runtime.restore_worker_operation(&worker.worker_ref, stale), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!(runtime.worker_detail(&worker.worker_ref).unwrap(), before); let state = runtime.lock().unwrap(); @@ -12732,7 +12785,7 @@ mod tests { runtime.stop_worker(&worker.worker_ref, None).unwrap(); assert!(matches!( runtime.restore_worker_operation(&worker.worker_ref, other_intent), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); let stopped = runtime.worker_detail(&worker.worker_ref).unwrap(); assert_ne!( @@ -12786,7 +12839,7 @@ mod tests { ); assert!(matches!( runtime.restore_worker_operation(&worker.worker_ref, request), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!(*backend.restore_count.lock().unwrap(), 0); } @@ -12824,7 +12877,7 @@ mod tests { ); assert!(matches!( second.join().unwrap(), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!(*backend.restore_count.lock().unwrap(), 1); assert!( @@ -13158,7 +13211,7 @@ mod tests { competitor.request_id = "different-intent".into(); assert!(matches!( resumed.coordinate_worker_restore_operation(&worker.worker_ref, competitor, None), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!(*backend.restore_count.lock().unwrap(), count); *backend.restore_result.lock().unwrap() = None; @@ -13570,7 +13623,7 @@ mod tests { stale.request_id = "stale-after-restart".into(); assert!(matches!( restarted.restore_worker_operation(&worker.worker_ref, stale), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); assert_eq!( restarted.worker_detail(&worker.worker_ref).unwrap().status, @@ -17040,7 +17093,7 @@ mod tests { stale.request_id = "new-intent-with-old-token".into(); assert!(matches!( restarted.restore_worker_operation(&worker.worker_ref, stale), - Err(RuntimeError::RestoreObservationConflict { .. }) + Err(RuntimeError::RestoreObservationStale { .. }) )); } } diff --git a/crates/worker-runtime/src/worker_backend_restore_publication_tests.rs b/crates/worker-runtime/src/worker_backend_restore_publication_tests.rs index 965572014..fb46602c3 100644 --- a/crates/worker-runtime/src/worker_backend_restore_publication_tests.rs +++ b/crates/worker-runtime/src/worker_backend_restore_publication_tests.rs @@ -1231,7 +1231,7 @@ mod publication { let mut stale = request; stale.request_id = "vertical-new-stale-intent".into(); assert!( - matches!(runtime.restore_worker_operation_scoped(&scope, &worker_ref, stale), Err(crate::error::RuntimeError::RestoreObservationConflict { worker_id }) if worker_id == worker_ref.worker_id) + matches!(runtime.restore_worker_operation_scoped(&scope, &worker_ref, stale), Err(crate::error::RuntimeError::RestoreObservationStale { worker_id }) if worker_id == worker_ref.worker_id) ); runtime .stop_worker_scoped(&scope, &worker_ref, None) diff --git a/crates/worker-runtime/src/worker_backend_restore_registration_tests.rs b/crates/worker-runtime/src/worker_backend_restore_registration_tests.rs index b6767d951..741057dc1 100644 --- a/crates/worker-runtime/src/worker_backend_restore_registration_tests.rs +++ b/crates/worker-runtime/src/worker_backend_restore_registration_tests.rs @@ -831,7 +831,7 @@ fn published_terminal_receipt_fault(fault: TerminalFault) { stale.request_id = "terminal-fresh-stale-intent".into(); assert!(matches!( runtime.restore_worker_operation_scoped(&scope, &worker_ref, stale), - Err(crate::error::RuntimeError::RestoreObservationConflict { .. }) + Err(crate::error::RuntimeError::RestoreObservationStale { .. }) )); assert_single_publication(&fixture, 1); assert_eq!( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d95404c1d..c0edea958 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -880,14 +880,16 @@ impl RuntimeRegistryError { code, message, } => { - if code == "restore_observation_conflict" { + if matches!( + code.as_str(), + "restore_observation_stale" | "stop_observation_conflict" + ) { + Error::WorkerLifecycleObservationStale + } else if code == "restore_observation_conflict" { Error::RestoreObservationConflict } else if matches!( code.as_str(), - "stop_observation_conflict" - | "stop_request_conflict" - | "stop_admission_busy" - | "restore_admission_busy" + "stop_request_conflict" | "stop_admission_busy" | "restore_admission_busy" ) { Error::RepositoryConflict(message) } else { @@ -6675,6 +6677,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti HostDiagnosticSeverity::Warning, "Stop request ID was already used with different input", ), + EmbeddedRuntimeError::RestoreObservationStale { .. } => diagnostic( + "restore_observation_stale", + HostDiagnosticSeverity::Warning, + "Restore observation changed; this request was not accepted", + ), EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic( "restore_observation_conflict", HostDiagnosticSeverity::Warning, diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 83b94b69f..b44bbfdab 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -169,6 +169,10 @@ pub enum Error { "restore_observation_conflict: Restore observation changed; refresh before a new intent" )] RestoreObservationConflict, + #[error("Subject body changed; reload before a new lifecycle intent")] + SubjectBodySuperseded, + #[error("Worker lifecycle observation is stale; this request was not accepted")] + WorkerLifecycleObservationStale, #[error("Repository conflict: {0}")] RepositoryConflict(String), #[error("Registry inconsistency: {0}")] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 42bf14a6e..7722d2dbe 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6782,6 +6782,13 @@ impl WorkspaceApi { }, ) .map_err(RuntimeRegistryError::into_error)?; + if restored + .diagnostics + .iter() + .any(|d| d.code == "restore_observation_stale") + { + return Err(Error::WorkerLifecycleObservationStale.into()); + } if restored .diagnostics .iter() @@ -10242,17 +10249,19 @@ impl server_api::ServerApi for ServerApiContractService { workspace_id: String, subject_id: String, request: server_api::SubjectBodyStartRequest, - ) -> std::result::Result + ) -> std::result::Result { subject_body::start( self.workspace_api()?.clone(), context, - workspace_id, - subject_id, - request, + workspace_id.clone(), + subject_id.clone(), + request.clone(), ) .await - .map_err(ApiError::into_repository_api_error) + .map_err(|error| { + subject_body::api_error(error, workspace_id, subject_id, request.request_id) + }) } async fn subjektiv_subject_body_stop( @@ -10261,17 +10270,19 @@ impl server_api::ServerApi for ServerApiContractService { workspace_id: String, subject_id: String, request: server_api::SubjectBodyStopRequest, - ) -> std::result::Result + ) -> std::result::Result { subject_body::stop( self.workspace_api()?.clone(), context, - workspace_id, - subject_id, - request, + workspace_id.clone(), + subject_id.clone(), + request.clone(), ) .await - .map_err(ApiError::into_repository_api_error) + .map_err(|error| { + subject_body::api_error(error, workspace_id, subject_id, request.request_id) + }) } async fn subjektiv_subject_body_stop_outcome( @@ -10282,17 +10293,19 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::SubjectBodyStopRequest, ) -> std::result::Result< server_api::SubjectBodyStopOutcomeResponse, - server_api::RepositoryApiError, + server_api::SubjectBodyApiError, > { subject_body::stop_outcome( self.workspace_api()?.clone(), context, - workspace_id, - subject_id, - request, + workspace_id.clone(), + subject_id.clone(), + request.clone(), ) .await - .map_err(ApiError::into_repository_api_error) + .map_err(|error| { + subject_body::api_error(error, workspace_id, subject_id, request.request_id) + }) } async fn subjektiv_subject_behavior_update( @@ -34932,7 +34945,10 @@ fn resume_internal_worker_restore_intent( // A definitive non-admission conflict is not unknown delivery. Retire // only this pinned intent, without retrying it or exchanging its // observation. A later deliberate invocation may observe anew. - if matches!(error.error, Error::RestoreObservationConflict) { + if matches!( + error.error, + Error::RestoreObservationConflict | Error::WorkerLifecycleObservationStale + ) { api.config_store.settle_internal_worker_restore_intent( api.workspace_id(), worker, @@ -39169,6 +39185,8 @@ fn api_error_status(error: &Error) -> StatusCode { | Error::RuntimeBindingIdConflict { .. } | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) + | Error::SubjectBodySuperseded + | Error::WorkerLifecycleObservationStale | Error::RestoreObservationConflict => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, Error::InvalidInput(message) diff --git a/crates/workspace-server/src/server/subject_body.rs b/crates/workspace-server/src/server/subject_body.rs index 278ed3df7..b1868505c 100644 --- a/crates/workspace-server/src/server/subject_body.rs +++ b/crates/workspace-server/src/server/subject_body.rs @@ -6,6 +6,28 @@ use server_api::{ SubjectBodyResponse, SubjectBodyStartRequest, SubjectBodyStopRequest, }; +pub(super) fn api_error( + error: ApiError, + workspace_id: String, + subject_id: String, + request_id: String, +) -> server_api::SubjectBodyApiError { + use server_api::SubjectBodyRequestResolution; + let resolution = match &error.error { + Error::SubjectBodySuperseded => SubjectBodyRequestResolution::Superseded, + Error::WorkerLifecycleObservationStale => SubjectBodyRequestResolution::NotAccepted, + _ => SubjectBodyRequestResolution::Unresolved, + }; + let mut response = server_api::SubjectBodyApiError::from(error.into_repository_api_error()); + response.request = Some(server_api::SubjectBodyFailedRequest { + workspace_id, + subject_id, + request_id, + resolution, + }); + response +} + fn diagnostic(code: &str, message: String) -> server_api::Diagnostic { server_api::Diagnostic { code: code.into(), @@ -61,10 +83,7 @@ pub(super) fn require_expected( ) -> ApiResult> { let lease = current(api, subject)?; if lease.as_ref().map(reference).as_ref() != expected { - return Err(Error::RepositoryConflict( - "Subject body changed; reload before a new lifecycle intent".into(), - ) - .into()); + return Err(Error::SubjectBodySuperseded.into()); } Ok(lease) } @@ -222,11 +241,8 @@ pub(super) async fn stop_outcome( .store .db_executor() .run(move || { - require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| { - ApiError::from(Error::RepositoryConflict( - "Subject has no current body".into(), - )) - }) + require_expected(&lookup_api, &lookup_subject, Some(&expected))? + .ok_or_else(|| ApiError::from(Error::SubjectBodySuperseded)) }) .await?; let worker = @@ -271,11 +287,8 @@ pub(super) async fn stop( .store .db_executor() .run(move || { - require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| { - ApiError::from(Error::RepositoryConflict( - "Subject has no current body".into(), - )) - }) + require_expected(&lookup_api, &lookup_subject, Some(&expected))? + .ok_or_else(|| ApiError::from(Error::SubjectBodySuperseded)) }) .await?; let worker = @@ -301,7 +314,9 @@ pub(super) async fn stop( Err(error) if matches!( error.error, - Error::RepositoryConflict(_) + Error::SubjectBodySuperseded + | Error::WorkerLifecycleObservationStale + | Error::RepositoryConflict(_) | Error::WorkspacePermissionDenied(_) | Error::RestoreObservationConflict ) => @@ -344,9 +359,7 @@ pub(super) async fn start( ) .await?; if observed.current.as_ref() != Some(expected) { - return Err( - Error::RepositoryConflict("Subject body changed before start".into()).into(), - ); + return Err(Error::SubjectBodySuperseded.into()); } let SubjectBodyObservation::Available { .. } = observed.observation else { return Err(Error::RepositoryConflict( @@ -392,7 +405,9 @@ pub(super) async fn start( Err(error) if matches!( error.error, - Error::RepositoryConflict(_) + Error::SubjectBodySuperseded + | Error::WorkerLifecycleObservationStale + | Error::RepositoryConflict(_) | Error::WorkspacePermissionDenied(_) | Error::RestoreObservationConflict ) => @@ -426,6 +441,7 @@ pub(super) async fn start( let create_workspace = workspace.clone(); let request_id = request.request_id.clone(); let operation = operation_id(&account, &workspace, &subject, &request_id); + let check_operation = operation.clone(); let result = run_host_workflow(api.host_executors.host()?, async move { let subject_record = require_subject(&create_api, &create_subject)?; // An exact creation retry uses the same ordinary reservation, including its @@ -496,11 +512,35 @@ pub(super) async fn start( Err(error) if matches!( error.error, - Error::RepositoryConflict(_) + Error::SubjectBodySuperseded + | Error::WorkerLifecycleObservationStale + | Error::RepositoryConflict(_) | Error::WorkspacePermissionDenied(_) | Error::SubjektivSubjectNotFound(_) ) => { + // A racing absent intent may lose after its initial observation but + // before allocation. Only absence of its own reservation proves that + // this screen's intent cannot affect the now-current body. + if matches!(error.error, Error::RepositoryConflict(_)) { + let lookup_api = api.clone(); + let lookup_workspace = workspace.clone(); + let lookup_subject = subject.clone(); + let superseded = api + .store + .db_executor() + .run(move || -> ApiResult { + Ok(lookup_api + .config_store + .reserved_worker_create_runtime(&lookup_workspace, &check_operation)? + .is_none() + && current(&lookup_api, &lookup_subject)?.is_some()) + }) + .await?; + if superseded { + return Err(Error::SubjectBodySuperseded.into()); + } + } return Err(error); } Err(error) => ( diff --git a/crates/workspace-server/src/server/tests/subject_body_connected_tests.rs b/crates/workspace-server/src/server/tests/subject_body_connected_tests.rs index a0de8c448..f71c471c3 100644 --- a/crates/workspace-server/src/server/tests/subject_body_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_body_connected_tests.rs @@ -11,6 +11,7 @@ //! `tests/support/restore_environment.rs` before any fixture starts. This module //! never sets process environment, installs tools, or contacts a live model. //! Resource races and Workdir rebinding belong to the separate lifecycle suite. +use super::subject_body_tests::assert_failed_request; use super::*; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -460,6 +461,7 @@ async fn body_same_request_concurrent_start_stop_outcome_and_restore_preserve_su StatusCode::CONFLICT, "old Runtime observation: {result}" ); + assert_failed_request(&result, &subject, "body-old-observation", "not_accepted"); } // Reopen authority rather than treating an earlier model callback as durable evidence. @@ -534,11 +536,24 @@ async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targe .any(|(status, body)| *status == StatusCode::OK && body["disposition"] == "accepted"), "one request must start the body: {a:?}, {b:?}" ); - for (status, body) in [&a, &b] { - assert!( - matches!(*status, StatusCode::OK | StatusCode::CONFLICT), - "{status}: {body}" - ); + assert_eq!( + [&a, &b] + .iter() + .filter(|(status, _)| *status == StatusCode::CONFLICT) + .count(), + 1, + "a distinct absent-start loser is terminal, not an unknown accepted operation: {a:?}, {b:?}" + ); + for (request_id, (status, body)) in [("body-click-a", &a), ("body-click-b", &b)] { + if *status == StatusCode::CONFLICT { + assert_failed_request(body, &subject, request_id, "superseded"); + let (retry_status, retry) = f.request("POST", &path, absent_start(request_id)).await; + assert_eq!(retry_status, StatusCode::CONFLICT, "{retry}"); + assert_failed_request(&retry, &subject, request_id, "superseded"); + } else { + assert_eq!(*status, StatusCode::OK, "{body}"); + assert_eq!(body["disposition"], "accepted", "{body}"); + } } assert_eq!(f.worker_count(), 1); let current = f.read(&subject).await; @@ -556,6 +571,7 @@ async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targe StatusCode::CONFLICT, "cross-Subject {suffix}: {result}" ); + assert_failed_request(&result, &other, "body-foreign-intent", "superseded"); } let mut old_generation = foreign.clone(); old_generation["expected_current"]["generation"] = json!( @@ -580,6 +596,7 @@ async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targe StatusCode::CONFLICT, "stale generation {suffix}: {result}" ); + assert_failed_request(&result, &subject, "body-foreign-intent", "superseded"); } let reader = seed_test_api_token(f.connected.api.store.as_ref(), "connected-body-reader"); for (method, suffix, body) in [ @@ -603,6 +620,10 @@ async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targe StatusCode::FORBIDDEN, "non-owner {suffix}: {result}" ); + assert!( + result["request"].is_null(), + "authorization before action admission supplies no request resolution: {result}" + ); } let wrong_workspace = body_path(&subject).replace(TEST_WORKSPACE_ID, "not-this-workspace"); let (status, result) = f @@ -704,3 +725,126 @@ async fn body_start_and_restore_do_not_repair_missing_session_record_permission( "stopped" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn body_retained_stop_and_restore_input_mismatches_remain_unresolved() { + let f = ConnectedBody::new().await; + let subject = f.subject("body-input-mismatch-subject").await; + f.mutate( + &subject, + "/start", + absent_start("body-input-mismatch-start"), + ) + .await; + let original_stop = f.stop(&subject, "body-owned-stop").await; + let mut changed_stop = original_stop.clone(); + changed_stop["expected_observation_token"] = json!("different-input-same-stop-id"); + for suffix in ["/stop", "/stop/outcome"] { + let (status, error) = f + .request( + "POST", + &format!("{}{suffix}", body_path(&subject)), + changed_stop.clone(), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{error}"); + assert_failed_request(&error, &subject, "body-owned-stop", "unresolved"); + } + let stopped = f.read(&subject).await; + let original_restore = intent("body-owned-restore", &stopped); + let restored = f.mutate(&subject, "/start", original_restore.clone()).await; + assert_eq!(restored["disposition"], "accepted", "{restored}"); + let mut changed_restore = original_restore.clone(); + changed_restore["expected_observation_token"] = json!("different-input-same-restore-id"); + let (status, error) = f + .request( + "POST", + &format!("{}/start", body_path(&subject)), + changed_restore, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{error}"); + assert_failed_request(&error, &subject, "body-owned-restore", "unresolved"); + let replay = f.mutate(&subject, "/start", original_restore).await; + assert_eq!(replay["request_id"], "body-owned-restore"); + assert_eq!(replay["disposition"], "accepted", "{replay}"); + assert_eq!(f.read(&subject).await["current"], stopped["current"]); + assert_eq!(f.worker_count(), 1); + f.stop(&subject, "body-input-mismatch-cleanup").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn body_pending_lifecycle_conflicts_do_not_claim_stale_request_nonacceptance() { + let f = ConnectedBody::new().await; + let subject = f.subject("body-busy-subject").await; + f.mutate(&subject, "/start", absent_start("body-busy-start")) + .await; + let running = f.read(&subject).await; + let pending = intent("body-pending-stop", &running); + let source = RuntimeWorkerRef::new( + CONNECTED_RUNTIME, + running["current"]["worker_id"].as_str().unwrap(), + ); + let api = f.connected.api.clone(); + let token = pending["expected_observation_token"] + .as_str() + .unwrap() + .to_owned(); + let runtime_request = WorkerLifecycleRequest { + reason: Some("Stopped from the Subject screen; Subject data is retained".into()), + ticket_assignment: None, + stop_intent: Some(runtime_api::WorkerStopIntent { + expected_observation_token: token, + request_id: "body-pending-stop".into(), + }), + }; + let admission_source = source.clone(); + let admission_request = runtime_request.clone(); + // Use the production signed Runtime admission, without executing cleanup yet. + // The raw Runtime intent is not in the browser caller's hashed namespace. + let admission = tokio::task::spawn_blocking(move || { + api.runtime + .coordinate_worker_stop(&admission_source, admission_request) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(admission, runtime_api::WorkerStopOutcome::Pending); + let occupied = f.read(&subject).await; + let request = intent("body-busy-restore", &occupied); + for _ in 0..2 { + let (status, error) = f + .request( + "POST", + &format!("{}/start", body_path(&subject)), + request.clone(), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{error}"); + assert_failed_request(&error, &subject, "body-busy-restore", "unresolved"); + } + assert_eq!(f.read(&subject).await["current"], running["current"]); + assert_eq!(f.worker_count(), 1); + let api = f.connected.api.clone(); + tokio::task::spawn_blocking(move || { + assert_eq!( + api.runtime + .worker_stop_outcome(&source, runtime_request.clone()) + .unwrap(), + runtime_api::WorkerStopOutcome::Pending + ); + let completed = api + .runtime + .stop_worker(&source, runtime_request.clone()) + .unwrap(); + assert_eq!(completed.state, InternalWorkerOperationState::Accepted); + assert!(matches!( + api.runtime + .worker_stop_outcome(&source, runtime_request) + .unwrap(), + runtime_api::WorkerStopOutcome::Stopped { .. } + )); + }) + .await + .unwrap(); +} diff --git a/crates/workspace-server/src/server/tests/subject_body_tests.rs b/crates/workspace-server/src/server/tests/subject_body_tests.rs index e244dd9a6..af423f87f 100644 --- a/crates/workspace-server/src/server/tests/subject_body_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_body_tests.rs @@ -4,6 +4,27 @@ //! by the connected suites, not inferred from this projection/admission fixture. use super::*; +pub(super) fn assert_failed_request(body: &Value, subject: &str, request: &str, resolution: &str) { + assert!(body["error"].is_string(), "{body}"); + assert!(body["message"].is_string(), "{body}"); + assert!(body["diagnostics"].is_array(), "{body}"); + assert!( + body.get("status").is_none(), + "HTTP status is not a body field: {body}" + ); + serde_json::from_value::(body.clone()) + .expect("the actual HTTP error must satisfy the dedicated typed shape"); + assert_eq!( + body["request"], + json!({ + "workspace_id": TEST_WORKSPACE_ID, + "subject_id": subject, + "request_id": request, + "resolution": resolution, + }) + ); +} + struct BodyFixture { api: WorkspaceApi, owner: String, @@ -120,6 +141,10 @@ async fn body_operations_reject_nonowners_and_cross_workspace_targets() { ) .await; assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!( + body["request"].is_null(), + "authorization before action admission supplies no request resolution: {body}" + ); } let path = f.path().replace(TEST_WORKSPACE_ID, "another-workspace"); let (status, body) = response(&f.api, human_request(&f.owner, "GET", &path, None)).await; @@ -196,11 +221,17 @@ async fn body_mutations_reject_an_old_generation_without_releasing_its_owner() { &f.owner, "POST", &format!("{}{suffix}", f.path()), - Some(payload), + Some(payload.clone()), ), ) .await; assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_failed_request( + &body, + &f.subject, + payload["request_id"].as_str().unwrap(), + "superseded", + ); } assert_eq!( f.read().await["current"], @@ -280,3 +311,25 @@ async fn body_mutation_json_requires_explicit_identity_and_fences() { } assert!(f.read().await["current"].is_null()); } + +#[tokio::test] +async fn body_action_payload_rejection_cannot_claim_request_resolution() { + let f = BodyFixture::new().await; + for suffix in ["/start", "/stop", "/stop/outcome"] { + let (status, body) = response( + &f.api, + human_request( + &f.owner, + "POST", + &format!("{}{suffix}", f.path()), + Some(json!({"request_id":"unbound-malformed"})), + ), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!( + body["request"].is_null(), + "unparsed intent is not evidence: {body}" + ); + } +} diff --git a/crates/workspace-server/src/server/worker_operations.rs b/crates/workspace-server/src/server/worker_operations.rs index c838dd70f..6157522ee 100644 --- a/crates/workspace-server/src/server/worker_operations.rs +++ b/crates/workspace-server/src/server/worker_operations.rs @@ -626,12 +626,18 @@ impl WorkspaceWorker { .runtime .stop_worker(&worker.identity, request) .map_err(RuntimeRegistryError::into_error)?; - if let Some(conflict) = result.diagnostics.iter().find(|diagnostic| { - matches!( - diagnostic.code.as_str(), - "stop_observation_conflict" | "stop_request_conflict" - ) - }) { + if result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "stop_observation_conflict") + { + return Err(Error::WorkerLifecycleObservationStale.into()); + } + if let Some(conflict) = result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "stop_request_conflict") + { return Err(Error::RepositoryConflict(conflict.message.clone()).into()); } if result.state != InternalWorkerOperationState::Accepted || result.replayed { diff --git a/openapi/server-api.json b/openapi/server-api.json index b45c3dc02..0791dc452 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -14799,6 +14799,40 @@ ], "type": "object" }, + "SubjectBodyApiError": { + "additionalProperties": false, + "description": "Only action handlers with a causally bound request may provide resolution evidence.", + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectBodyFailedRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "error", + "message", + "diagnostics" + ], + "type": "object" + }, "SubjectBodyDisposition": { "enum": [ "accepted", @@ -14807,6 +14841,30 @@ ], "type": "string" }, + "SubjectBodyFailedRequest": { + "additionalProperties": false, + "properties": { + "request_id": { + "type": "string" + }, + "resolution": { + "$ref": "#/components/schemas/SubjectBodyRequestResolution" + }, + "subject_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "subject_id", + "request_id", + "resolution" + ], + "type": "object" + }, "SubjectBodyMutationResponse": { "additionalProperties": false, "properties": { @@ -14917,6 +14975,15 @@ ], "type": "object" }, + "SubjectBodyRequestResolution": { + "description": "Resolution of this screen's intent, not a judgment about historical operation success.", + "enum": [ + "not_accepted", + "superseded", + "unresolved" + ], + "type": "string" + }, "SubjectBodyResponse": { "additionalProperties": false, "properties": { @@ -23026,7 +23093,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a" + "x-yoi-source-digest": "sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a" }, "openapi": "3.1.0", "paths": { @@ -42368,7 +42435,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42378,7 +42445,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42388,7 +42455,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42398,7 +42465,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42408,7 +42475,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42418,7 +42485,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42485,7 +42552,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42495,7 +42562,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42505,7 +42572,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42515,7 +42582,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42525,7 +42592,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42535,7 +42602,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42602,7 +42669,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42612,7 +42679,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42622,7 +42689,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42632,7 +42699,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42642,7 +42709,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42652,7 +42719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, @@ -42662,7 +42729,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositoryApiError" + "$ref": "#/components/schemas/SubjectBodyApiError" } } }, diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 48e4042cf..967745d17 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a) +// Input contract: 0.1.0 (sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index b13d52985..407b14c1b 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a) +// Input contract: 0.1.0 (sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 51405e46d..7f017e00e 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a) +// Input contract: 0.1.0 (sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/subject-body-api.ts b/web/workspace/src/lib/generated/subject-body-api.ts index 177cc0446..5654201fe 100644 --- a/web/workspace/src/lib/generated/subject-body-api.ts +++ b/web/workspace/src/lib/generated/subject-body-api.ts @@ -1,7 +1,26 @@ // Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`. // Do not edit manually. -import type { Diagnostic, WorkerSummary } from "./worker-launch-api"; +import type { Diagnostic, WorkerSummary } from "./worker-launch-api.ts"; + +export type SubjectBodyRequestResolution = + | "not_accepted" + | "superseded" + | "unresolved"; + +export type SubjectBodyFailedRequest = { + workspace_id: string; + subject_id: string; + request_id: string; + resolution: SubjectBodyRequestResolution; +}; + +export type SubjectBodyApiError = { + error: string; + message: string; + diagnostics: Array; + request: SubjectBodyFailedRequest | null; +}; export type SubjectBodyRef = { runtime_id: string; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 58a6ef758..c23b13ad6 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a) +// Input contract: 0.1.0 (sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 5c5805f3d..9f698c7d3 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a) +// Input contract: 0.1.0 (sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/memory/SubjectBody.browser.spec.ts b/web/workspace/src/lib/workspace/memory/SubjectBody.browser.spec.ts index 62e6a28a9..42094f518 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectBody.browser.spec.ts +++ b/web/workspace/src/lib/workspace/memory/SubjectBody.browser.spec.ts @@ -9,6 +9,10 @@ import { import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { tick } from "svelte"; import SubjectBody from "./SubjectBody.svelte"; +import type { + SubjectBodyApiError as BodyErrorResponse, + SubjectBodyFailedRequest, +} from "#lib/generated/subject-body-api.ts"; import { readSubjectBodyAttempt, subjectBodyStorageKey, @@ -59,6 +63,31 @@ function available(state = "stopped", observationToken = "observation-1") { }, }; } +function failedRequest( + request_id: string, + resolution: SubjectBodyFailedRequest["resolution"] | null, + status = 409, +) { + const body: BodyErrorResponse = { + error: "Conflict", + message: "Body admission could not use the saved observation", + diagnostics: [{ + code: "body_conflict", + severity: "warning", + message: "Refresh the current body observation", + }], + request: resolution + ? { + workspace_id: props.workspaceId, + subject_id: props.subjectId, + request_id, + resolution, + } + : null, + }; + return Response.json(body, { status }); +} + function savedStop(accountId = props.accountId) { return { accountId, @@ -1220,26 +1249,39 @@ test("old generation rejection stays distinct from fresh body and requires a fre observation = available("idle", "generation-18-token"); observation.current = { ...owner, generation: "18" }; return Response.json({ - request_id: request.request_id, - disposition: "rejected", + error: "Conflict", + message: "The current body generation changed", diagnostics: [{ code: "stale_owner", severity: "warning", message: "The current body generation changed. Refresh before acting.", }], - body: observation, - }); + request: { + workspace_id: props.workspaceId, + subject_id: props.subjectId, + request_id: request.request_id, + resolution: "superseded", + }, + }, { status: 409 }); }), ); render(SubjectBody, props); await fireEvent.click( await screen.findByRole("button", { name: "Stop conversation body" }), ); - await screen.findByText("Request rejected."); + await screen.findByText(/^Request superseded\./); await screen.findByText( - "The current body generation changed. Refresh before acting.", + /The current body generation changed\. Refresh before acting\./, ); + expect( + readSubjectBodyAttempt( + sessionStorage, + props.accountId, + props.workspaceId, + props.subjectId, + ), + ).toBeNull(); await waitFor(() => expect( (screen.getByRole("button", { @@ -1329,3 +1371,195 @@ test("unknown stop cannot restart on GET alone; confirmed outcome permits restar expect(request.expected_observation_token).toBe("after-confirmed-stop"); expect(request.request_id).not.toBe(JSON.parse(posts[0].body).request_id); }); + +test.each(["stale-token", "concurrent-absent-start"])( + "%s terminal admission permits only a new explicit action after refresh", + async (scenario) => { + let observation = scenario === "stale-token" + ? available("idle", "old-token") + : absent(); + const posts: { path: string; body: string }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (path: string, init?: RequestInit) => { + if (init?.method !== "POST") return Response.json(observation); + posts.push({ path, body: init.body as string }); + const request = JSON.parse(init.body as string); + observation = available("idle", "fresh-token"); + return failedRequest( + request.request_id, + scenario === "stale-token" ? "not_accepted" : "superseded", + ); + }), + ); + const view = render(SubjectBody, props); + await fireEvent.click( + await screen.findByRole("button", { + name: scenario === "stale-token" + ? "Stop conversation body" + : "Start conversation body", + }), + ); + await screen.findByText( + scenario === "stale-token" + ? /^Request not accepted\./ + : /^Request superseded\./, + ); + const stop = await screen.findByRole("button", { + name: "Stop conversation body", + }); + await waitFor(() => + expect((stop as HTMLButtonElement).disabled).toBe(false) + ); + expect(posts).toHaveLength(1); + expect( + readSubjectBodyAttempt( + sessionStorage, + props.accountId, + props.workspaceId, + props.subjectId, + ), + ).toBeNull(); + // 再読込みで未確定要求は復元されず、新しい操作も自動送信しない。 + view.unmount(); + render(SubjectBody, props); + const reloaded = await screen.findByRole("button", { + name: "Stop conversation body", + }); + expect(posts).toHaveLength(1); + await fireEvent.click(reloaded); + await waitFor(() => expect(posts).toHaveLength(2)); + const oldInput = JSON.parse(posts[0].body); + const nextInput = JSON.parse(posts[1].body); + expect(oldInput.expected_observation_token).toBe( + scenario === "stale-token" ? "old-token" : null, + ); + expect(nextInput.expected_observation_token).toBe("fresh-token"); + expect(nextInput.expected_current).toEqual(owner); + expect(nextInput.request_id).not.toBe(oldInput.request_id); + expect(posts[1].path.endsWith("/stop")).toBe(true); + }, +); + +test.each(["busy", "generic-conflict", "foreign-request", "unauthorized"])( + "%s does not settle or replace a saved request on refresh or reload", + async (scenario) => { + const posts: string[] = []; + let observation = available("idle", "old-token"); + vi.stubGlobal( + "fetch", + vi.fn(async (_path: string, init?: RequestInit) => { + if (init?.method !== "POST") return Response.json(observation); + const raw = init.body as string; + posts.push(raw); + const { request_id } = JSON.parse(raw); + observation = available("idle", "new-token"); + return failedRequest( + scenario === "foreign-request" ? "foreign" : request_id, + scenario === "busy" + ? "unresolved" + : scenario === "generic-conflict" + ? null + : "not_accepted", + scenario === "unauthorized" ? 401 : 409, + ); + }), + ); + const view = render(SubjectBody, props); + await fireEvent.click( + await screen.findByRole("button", { name: "Stop conversation body" }), + ); + await screen.findByText(/Request outcome unknown\. The request may/); + const key = subjectBodyStorageKey( + props.accountId, + props.workspaceId, + props.subjectId, + ); + const saved = sessionStorage.getItem(key); + expect(saved).not.toBeNull(); + const retry = await screen.findByRole("button", { + name: "Retry same stop request", + }); + await waitFor(() => + expect((retry as HTMLButtonElement).disabled).toBe(false) + ); + await fireEvent.click(retry); + await waitFor(() => expect(posts).toHaveLength(2)); + expect(posts[1]).toBe(posts[0]); + await waitFor(() => + expect( + (screen.getByRole("button", { + name: "Refresh body state", + }) as HTMLButtonElement).disabled, + ).toBe(false) + ); + await fireEvent.click( + screen.getByRole("button", { name: "Refresh body state" }), + ); + await waitFor(() => + expect( + (screen.getByRole("button", { + name: "Refresh body state", + }) as HTMLButtonElement).disabled, + ).toBe(false) + ); + expect(sessionStorage.getItem(key)).toBe(saved); + view.unmount(); + render(SubjectBody, props); + await screen.findByRole("button", { name: "Retry same stop request" }); + expect(screen.queryByRole("button", { name: "Stop conversation body" })) + .toBeNull(); + expect(sessionStorage.getItem(key)).toBe(saved); + expect(posts).toHaveLength(2); + }, +); + +test("lost stop stays unknown until an identity-bound supersession from outcome permits a fresh explicit action", async () => { + let observation = available("idle"); + const posts: { path: string; body: string }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (path: string, init?: RequestInit) => { + if (init?.method !== "POST") return Response.json(observation); + posts.push({ path, body: init.body as string }); + const { request_id } = JSON.parse(init.body as string); + if (path.endsWith("/outcome")) { + return failedRequest(request_id, "superseded"); + } + observation = available("idle", "generation-18-token"); + observation.current = { ...owner, generation: "18" }; + throw new Error("response lost"); + }), + ); + render(SubjectBody, props); + await fireEvent.click( + await screen.findByRole("button", { name: "Stop conversation body" }), + ); + await screen.findByText(/Request outcome unknown\. The request may/); + const key = subjectBodyStorageKey( + props.accountId, + props.workspaceId, + props.subjectId, + ); + expect(sessionStorage.getItem(key)).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Stop conversation body" })) + .toBeNull(); + await fireEvent.click( + await screen.findByRole("button", { name: "Check stop outcome" }), + ); + await screen.findByText(/^Request superseded\./); + expect(sessionStorage.getItem(key)).toBeNull(); + expect(posts).toHaveLength(2); + expect(posts[1].body).toBe(posts[0].body); + expect(screen.queryByText("Historical stop confirmed.")).toBeNull(); + const stop = await screen.findByRole("button", { + name: "Stop conversation body", + }); + await waitFor(() => expect((stop as HTMLButtonElement).disabled).toBe(false)); + await fireEvent.click(stop); + await waitFor(() => expect(posts).toHaveLength(3)); + expect(JSON.parse(posts[2].body).expected_current.generation).toBe("18"); + expect(JSON.parse(posts[2].body).request_id).not.toBe( + JSON.parse(posts[0].body).request_id, + ); +}); diff --git a/web/workspace/src/lib/workspace/memory/SubjectBody.svelte b/web/workspace/src/lib/workspace/memory/SubjectBody.svelte index 22edb6330..61b6d355b 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectBody.svelte +++ b/web/workspace/src/lib/workspace/memory/SubjectBody.svelte @@ -90,6 +90,24 @@ } } + function settleFailedAttempt(cause: unknown, frozen: SubjectBodyAttempt): string | null { + if (!(cause instanceof SubjectBodyApiError) || !cause.request || (cause.request.resolution !== 'not_accepted' && cause.request.resolution !== 'superseded')) return null; + const rejected = cause.request; + if (rejected.workspace_id !== frozen.workspaceId || rejected.subject_id !== frozen.subjectId || rejected.request_id !== frozen.request.request_id) return null; + receipt = null; + stopOutcome = null; + try { + clearSubjectBodyAttempt(sessionStorage, accountId, frozen); + attempt = null; + } catch { + storageError = 'The request rejection was confirmed, but its saved input could not be cleared. Restore session storage access and reload before another action.'; + } + return (rejected.resolution === 'superseded' + ? 'Request superseded. The body target changed, so this saved intent can no longer act on the current body. This does not determine whether an earlier operation took effect. ' + : 'Request not accepted. The saved observation was stale and this request was not admitted. ') + + cause.message + '. Refresh the current body before choosing a new explicit action.'; + } + async function submit(action?: 'start' | 'stop'): Promise { if (!visible || sending || checkingOutcome || loading || storageError || !fresh || !snapshot) return; if (attempt ? !retryAuthorized : (!action || !controls[action])) return; @@ -132,7 +150,7 @@ } } catch (cause) { if (!current(generation, account, workspace, subject)) return; - operationError = (cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Request outcome unknown. The request may already have taken effect. Its exact input is retained; refresh observations and explicitly retry the same request to reconcile.'; + operationError = settleFailedAttempt(cause, frozen) ?? ((cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Request outcome unknown. The request may already have taken effect. Its exact input is retained; refresh observations and explicitly retry the same request to reconcile.'); } finally { if (current(generation, account, workspace, subject)) { sending = false; @@ -170,7 +188,9 @@ void refresh(); } catch (cause) { if (!current(generation, account, workspace, subject)) return; - outcomeError = (cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Stop outcome could not be checked. This is not a missing receipt or a confirmed stop. The exact request is retained; try the read-only check again.'; + const settled = settleFailedAttempt(cause, frozen); + outcomeError = settled ?? ((cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Stop outcome could not be checked. This is not a missing receipt or a confirmed stop. The exact request is retained; try the read-only check again.'); + if (settled) { operationError = null; void refresh(); } } finally { if (current(generation, account, workspace, subject)) checkingOutcome = false; } diff --git a/web/workspace/src/lib/workspace/memory/subject-body.test.ts b/web/workspace/src/lib/workspace/memory/subject-body.test.ts index 6294a6795..0d31b2f88 100644 --- a/web/workspace/src/lib/workspace/memory/subject-body.test.ts +++ b/web/workspace/src/lib/workspace/memory/subject-body.test.ts @@ -10,6 +10,7 @@ import { saveSubjectBodyAttempt, startSubjectBody, stopSubjectBody, + SubjectBodyApiError, subjectBodyControls, subjectBodyStorageKey, } from "./subject-body.ts"; @@ -719,3 +720,121 @@ Deno.test("accountless legacy keys are neither discovered migrated nor cleared f createSubjectBodyAttempt("", parse(absent()), "start", "no-account") ); }); + +Deno.test("mutation failures expose only identity-bound dedicated 409 request resolutions", async () => { + const identity = { + workspace_id: "workspace", + subject_id: "subject", + request_id: "frozen-request", + }; + const errorBody = (request: unknown) => ({ + error: "Conflict", + message: "The saved observation is stale", + diagnostics: [{ + code: "stale_observation", + severity: "warning", + message: "Refresh before a new explicit action", + }], + request, + }); + const input = Object.freeze({ + request_id: identity.request_id, + expected_current: owner, + expected_observation_token: "frozen-token", + }); + const cases: { status: number; body: unknown; resolution: string | null }[] = + [ + ...["not_accepted", "superseded", "unresolved"].map((resolution) => ({ + status: 409, + body: errorBody({ ...identity, resolution }), + resolution, + })), + ...["workspace_id", "subject_id", "request_id"].map((field) => ({ + status: 409, + body: errorBody({ + ...identity, + [field]: "foreign", + resolution: "superseded", + }), + resolution: null, + })), + ...[401, 403, 500, 502].map((status) => ({ + status, + body: errorBody({ ...identity, resolution: "not_accepted" }), + resolution: null, + })), + { status: 409, body: errorBody(null), resolution: null }, + { + status: 409, + body: { error: "Conflict", message: "busy", diagnostics: [] }, + resolution: null, + }, + { + status: 409, + body: errorBody({ ...identity, resolution: "rejected" }), + resolution: null, + }, + { + status: 409, + body: errorBody({ ...identity, resolution: "superseded", extra: true }), + resolution: null, + }, + { + status: 409, + body: { + ...errorBody({ ...identity, resolution: "not_accepted" }), + diagnostics: "invalid", + }, + resolution: null, + }, + ]; + for (const item of cases) { + for ( + const invoke of [ + startSubjectBody, + stopSubjectBody, + getSubjectBodyStopOutcome, + ] + ) { + let sent = ""; + const fetchFn = ((_path: string, init?: RequestInit) => { + sent = init?.body as string; + return Promise.resolve( + Response.json(item.body, { status: item.status }), + ); + }) as typeof fetch; + let failure: unknown; + try { + await invoke(fetchFn, "workspace", "subject", input); + } catch (cause) { + failure = cause; + } + assert(failure instanceof SubjectBodyApiError); + assert((failure.request?.resolution ?? null) === item.resolution); + assert(sent === JSON.stringify(input)); + } + } +}); + +Deno.test("GET cannot supply a mutation resolution even when its error claims the saved request", async () => { + const fetchFn = (() => + Promise.resolve(Response.json({ + error: "Conflict", + message: "stale", + diagnostics: [], + request: { + workspace_id: "workspace", + subject_id: "subject", + request_id: "saved", + resolution: "superseded", + }, + }, { status: 409 }))) as typeof fetch; + let failure: unknown; + try { + await getSubjectBody(fetchFn, "workspace", "subject"); + } catch (cause) { + failure = cause; + } + assert(failure instanceof SubjectBodyApiError); + assert(failure.request === null); +}); diff --git a/web/workspace/src/lib/workspace/memory/subject-body.ts b/web/workspace/src/lib/workspace/memory/subject-body.ts index 5aa3794fb..5d531982f 100644 --- a/web/workspace/src/lib/workspace/memory/subject-body.ts +++ b/web/workspace/src/lib/workspace/memory/subject-body.ts @@ -1,4 +1,5 @@ import type { + SubjectBodyFailedRequest, SubjectBodyMutationResponse, SubjectBodyObservation, SubjectBodyRef, @@ -224,26 +225,86 @@ function bodyPath(workspaceId: string, subjectId: string): string { ); } -export class SubjectBodyApiError extends Error {} +export class SubjectBodyApiError extends Error { + constructor( + message: string, + readonly request: SubjectBodyFailedRequest | null = null, + ) { + super(message); + this.name = "SubjectBodyApiError"; + } +} -async function checkedJson(response: Response): Promise { +type RequestIdentity = { + workspaceId: string; + subjectId: string; + requestId: string; +}; + +function failedRequest( + value: unknown, + expected: RequestIdentity, +): SubjectBodyFailedRequest { + const item = record(value, [ + "workspace_id", + "subject_id", + "request_id", + "resolution", + ]); + const workspace_id = text(item.workspace_id); + const subject_id = text(item.subject_id); + const request_id = parseRequestId(item.request_id); + if ( + workspace_id !== expected.workspaceId || + subject_id !== expected.subjectId || + request_id !== expected.requestId + ) { + throw new Error("Subject body rejection does not match the frozen request"); + } + const resolution = item.resolution; + if ( + resolution !== "not_accepted" && resolution !== "superseded" && + resolution !== "unresolved" + ) { + throw new Error("Subject body request resolution is invalid"); + } + return { workspace_id, subject_id, request_id, resolution }; +} + +async function checkedJson( + response: Response, + expected?: RequestIdentity, +): Promise { if (!response.ok) { - // 生のerror bodyを表示せず、公開Diagnosticだけを境界で検査する。 + // GETやHTTP statusだけを、保存済み依頼の決済根拠にしない。 let detail = ""; + let request: SubjectBodyFailedRequest | null = null; try { const error = record( await readBoundedJson(response, MAX_RESPONSE_BYTES), - ["error", "message", "diagnostics"], + ["error", "message", "diagnostics", "request"], ); - if (error.diagnostics !== undefined) { - detail = diagnostics(error.diagnostics).map((item) => item.message) - .join("; ").slice(0, 1024); + text(error.error); + const message = text(error.message, 65536); + const publicDiagnostics = diagnostics(error.diagnostics); + detail = (publicDiagnostics.map((item) => + item.message + ).join("; ") || message) + .slice(0, 1024); + if (expected && error.request != null) { + const bound = failedRequest(error.request, expected); + // terminalなstale拒否は専用409契約だけ。未認可/別statusには流用しない。 + if (response.status === 409) request = bound; } - } catch { /* HTTP分類はbodyのdecode失敗でも保持する。 */ } + } catch { + // 不正なbodyやidentity不一致は決済せず、元の正確な入力を保持する。 + request = null; + } throw new SubjectBodyApiError( `Subject body request failed with HTTP ${response.status}${ detail ? `: ${detail}` : "" }`, + request, ); } return readBoundedJson(response, MAX_RESPONSE_BYTES); @@ -280,7 +341,7 @@ async function mutateSubjectBody( }, ); return parseSubjectBodyMutationResponse( - await checkedJson(response), + await checkedJson(response, { workspaceId, subjectId, requestId }), workspaceId, subjectId, requestId, @@ -324,7 +385,7 @@ export async function getSubjectBodyStopOutcome( ); // 認可拒否・通信失敗・不正な応答をnot_foundに読み替えない。 return parseSubjectBodyStopOutcomeResponse( - await checkedJson(response), + await checkedJson(response, { workspaceId, subjectId, requestId }), workspaceId, subjectId, requestId, diff --git a/web/workspace/test/subject-body-review.ts b/web/workspace/test/subject-body-review.ts index 7c5116bfc..629f1c2e5 100644 --- a/web/workspace/test/subject-body-review.ts +++ b/web/workspace/test/subject-body-review.ts @@ -3,6 +3,16 @@ import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { chromium } from "playwright"; +import type { + SubjectBodyApiError as SubjectBodyApiErrorPayload, + SubjectBodyFailedRequest, + SubjectBodyMutationResponse, + SubjectBodyRef, + SubjectBodyResponse, + SubjectBodyStartRequest, + SubjectBodyStopOutcomeResponse, + SubjectBodyStopRequest, +} from "../src/lib/generated/subject-body-api.ts"; import { sha256File } from "../../../tools/web-ux/src/artifacts.ts"; import { startOwnedProcesses, @@ -16,7 +26,9 @@ import { const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); const run = Deno.args[0] ?? "review"; if (!/^[\w-]+$/.test(run)) throw new Error("safe unique run id required"); -const output = join(root, "target/t807-web", run); +const outputBase = join(root, "target/t807-stale-web"); +await Deno.mkdir(outputBase, { recursive: true }); +const output = join(outputBase, run); await Deno.mkdir(output); // Never replace previous evidence. const buildRoot = join(root, "web/workspace/build"); await Deno.stat(join(buildRoot, "index.html")); @@ -42,7 +54,11 @@ const manifest = { expectedFaults: [ "structured session.record rejection", "unknown start/stop receipts", - "stale generation rejection", + "dedicated HTTP409 generation/absent-start superseded and stale-token not_accepted", + "HTTP409 superseded outcome is not historical stop failure or confirmation", + "busy unresolved, generic409, request input conflict and 401 retain input", + "mismatched identities and malformed terminal error shapes retain input", + "mutation response lost after synthetic admission; exact retry only", "503 body GET", "navigation teardown aborts GET /workers refresh", ], @@ -95,16 +111,41 @@ try { "absent"; let permission = true; let getFailure = false; - let action: "reject" | "unknown" | "accepted" | "stale" = "reject"; - let outcome: "pending" | "stopped" = "pending"; + type Action = + | "reject" + | "unknown" + | "accepted" + | "stale-generation" + | "stale-token" + | "absent-race" + | "busy" + | "generic-conflict" + | "input-conflict" + | "transport-lost" + | "unauthorized" + | "wrong-workspace" + | "wrong-subject" + | "wrong-request" + | "extra-request-field" + | "extra-error-field" + | "invalid-resolution"; + let action: Action = "reject"; + let outcome: SubjectBodyStopOutcomeResponse["outcome"] | "superseded" = + "pending"; let generation = "17"; + let tokenChanged = false; + let heldBodyRead: Promise | null = null; + const expectedHttp: { status: number; path: string }[] = []; + const expectedLost: string[] = []; const posts: { path: string; body: string }[] = []; - const owner = () => ({ + const owner = (): SubjectBodyRef => ({ runtime_id: "runtime", worker_id: "worker", generation, }); - function body(workspace: string, subject: string) { + const observationToken = () => + `token-${generation}-${observation}${tokenChanged ? "-fresh" : ""}`; + function body(workspace: string, subject: string): SubjectBodyResponse { return { workspace_id: workspace, subject_id: subject, @@ -142,8 +183,7 @@ try { worker_state: observation === "stopped" ? null : { last_command_id: 1, state: { kind: "idle" } }, - restore_observation_token: - `token-${generation}-${observation}`, + restore_observation_token: observationToken(), }, }, }; @@ -189,6 +229,8 @@ try { /\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/body/, )!; if (request.method() === "GET") { + if (heldBodyRead) await heldBodyRead; + if (getFailure) expectedHttp.push({ status: 503, path }); await route.fulfill({ status: getFailure ? 503 : 200, json: getFailure @@ -199,24 +241,142 @@ try { } const raw = request.postData()!; posts.push({ path, body: raw }); - const input = JSON.parse(raw); + const input: SubjectBodyStartRequest | SubjectBodyStopRequest = JSON + .parse(raw); assert(!("accountId" in input)); + const apiError = ( + resolution: SubjectBodyFailedRequest["resolution"] | null, + error: string, + message: string, + ): SubjectBodyApiErrorPayload => ({ + error, + message, + diagnostics: [{ code: error, severity: "warning", message }], + request: resolution === null ? null : { + workspace_id: workspace, + subject_id: subject, + request_id: input.request_id, + resolution, + }, + }); + const fail = async ( + json: unknown, + status = 409, + ) => { + expectedHttp.push({ status, path }); + await route.fulfill({ status, json }); + }; if (path.endsWith("/outcome")) { - await route.fulfill({ - json: { request_id: input.request_id, outcome, body: null }, - }); + if (outcome === "superseded") { + await fail(apiError( + "superseded", + "subject_body_request_superseded", + "The saved stop intent no longer targets the current body.", + )); + } else { + await route.fulfill({ + json: { + request_id: input.request_id, + outcome, + body: null, + } satisfies SubjectBodyStopOutcomeResponse, + }); + } + return; + } + if (action === "transport-lost") { + // 合成受付後に応答だけを失わせる。後続GETで本体が見えても要求は決済しない。 + observation = "idle"; + expectedLost.push(path); + await route.abort("failed"); + return; + } + if ( + action === "stale-generation" || action === "stale-token" || + action === "absent-race" + ) { + if (action === "stale-token") tokenChanged = true; + else generation = String(Number(generation) + 1); + if (action === "absent-race") observation = "idle"; + await fail(apiError( + action === "stale-token" ? "not_accepted" : "superseded", + action === "stale-token" + ? "worker_lifecycle_observation_stale" + : "subject_body_superseded", + action === "stale-token" + ? "The current body observation token changed." + : "The current body generation changed.", + )); + return; + } + if (action === "busy" || action === "input-conflict") { + await fail(apiError( + "unresolved", + action === "busy" + ? "subject_body_busy" + : "subject_body_request_input_conflict", + action === "busy" + ? "The body is busy; the saved request is unresolved." + : "This request ID is already bound to different input.", + )); + return; + } + if (action === "generic-conflict") { + // 要求の証拠がないstatusだけでは決済しない。 + await fail(apiError(null, "conflict", "Generic conflict")); + return; + } + if ( + action !== "reject" && action !== "unknown" && + action !== "accepted" + ) { + const terminal = apiError( + "not_accepted", + "subject_body_observation_stale", + "Synthetic stale observation rejection.", + ); + assert(terminal.request); + let json: unknown = terminal; + switch (action) { + case "unauthorized": + await fail(terminal, 401); + return; + case "wrong-workspace": + terminal.request.workspace_id = "other-review"; + break; + case "wrong-subject": + terminal.request.subject_id = "different-subject"; + break; + case "wrong-request": + terminal.request.request_id = "another-request"; + break; + case "extra-request-field": + json = { + ...terminal, + request: { ...terminal.request, extra: true }, + }; + break; + case "extra-error-field": + json = { ...terminal, extra: true }; + break; + case "invalid-resolution": + json = { + ...terminal, + request: { ...terminal.request, resolution: "rejected" }, + }; + break; + } + // 意図的な不正JSONはDTOへcastせずunknownのまま扱う。 + await fail(json); return; } - if (action === "stale") generation = "18"; if (action === "accepted" && path.endsWith("/start")) { observation = "idle"; } await route.fulfill({ json: { request_id: input.request_id, - disposition: action === "reject" || action === "stale" - ? "rejected" - : action, + disposition: action === "reject" ? "rejected" : action, diagnostics: action === "reject" ? [{ code: "permission_missing", @@ -224,21 +384,18 @@ try { message: "Required permission session.record is missing. Ask an administrator to review the default profile; no permissions were added.", }] - : action === "stale" - ? [{ - code: "stale_owner", - severity: "warning", - message: - "The current body generation changed. Refresh before acting.", - }] : [], body: body(workspace, subject), - }, + } satisfies SubjectBodyMutationResponse, }); }, ); const page = await context.newPage(); - const errors: unknown[] = []; + type BrowserDiagnostic = + | { kind: "page" | "console"; message: string } + | { kind: "request"; url: string; message: string | undefined } + | { kind: "http"; status: number; url: string }; + const errors: BrowserDiagnostic[] = []; let documentStatus: number | null = null; page.on( "pageerror", @@ -277,6 +434,61 @@ try { await button("Refresh body state").click(); await ready(); } + function holdBodyRead() { + let release!: () => void; + heldBodyRead = new Promise((resolve) => { + release = resolve; + }); + return () => { + heldBodyRead = null; + release(); + }; + } + const lastInput = (): + | SubjectBodyStartRequest + | SubjectBodyStopRequest => JSON.parse(posts.at(-1)!.body); + async function retainedAfterReadAndReload(kind: "start" | "stop") { + const count = posts.length; + await refresh(); + assertEquals(await button("Stop conversation body").count(), 0); + assertEquals(await button("Start conversation body").count(), 0); + assertEquals(await button("Restart conversation body").count(), 0); + assert(await button(`Retry same ${kind} request`).isEnabled()); + await page.reload(); + await panel().getByText( + new RegExp(`An unresolved ${kind} request is retained`), + ) + .waitFor(); + await ready(); + assert(await button(`Retry same ${kind} request`).isEnabled()); + assertEquals( + posts.length, + count, + "GET/reload must not send another mutation", + ); + } + async function freshStop( + previous: SubjectBodyStartRequest | SubjectBodyStopRequest, + ) { + const count = posts.length; + const currentOwner = owner(); + const token = observationToken(); + action = "accepted"; + await button("Stop conversation body").click(); + await panel().getByText("Request accepted.", { exact: true }) + .waitFor(); + await ready(); + assertEquals( + posts.length, + count + 1, + "one fresh explicit operation only", + ); + const next = lastInput(); + assert(next.request_id !== previous.request_id); + assertEquals(next.expected_current, currentOwner); + assertEquals(next.expected_observation_token, token); + return next; + } async function capture(name: string, expectedErrors: string[] = []) { await panel().scrollIntoViewIfNeeded(); const id = `${prefix}-${name}`; @@ -300,6 +512,8 @@ try { bodyText: await panel().innerText(), errors: [...errors], expectedErrors, + expectedHttp: [...expectedHttp], + expectedLost: [...expectedLost], image, imageSha256: await sha256File(image), panelImage, @@ -379,18 +593,177 @@ try { assertEquals(restart.expected_current.generation, "17"); assert(restart.request_id !== JSON.parse(stopInput).request_id); await capture("restarted-running"); - action = "stale"; + // 実契約の専用409で決済しても、独立した本体GETが終わるまで新操作は許可しない。 + action = "stale-generation"; + let releaseBody = holdBodyRead(); await button("Stop conversation body").click(); - await panel().getByText( - "The current body generation changed. Refresh before acting.", - { exact: true }, - ).waitFor(); + await panel().getByRole("alert").filter({ + hasText: /Request superseded\./, + }).waitFor(); + const staleGeneration = lastInput(); + assertEquals(staleGeneration.expected_current?.generation, "17"); + assert(await button("Stop conversation body").isDisabled()); + assertEquals(await button("Retry same stop request").count(), 0); + await capture("stale-generation-awaiting-get", [ + "intentional dedicated HTTP409 superseded", + ]); + const beforeGenerationRead = posts.length; + releaseBody(); await ready(); + assertEquals(posts.length, beforeGenerationRead); + await capture("old-generation-rejected", [ + "intentional dedicated HTTP409 superseded", + ]); + await freshStop(staleGeneration); + await capture("generation-fresh-explicit"); + + action = "stale-token"; + await button("Stop conversation body").click(); + await panel().getByRole("alert").filter({ + hasText: /Request not accepted\./, + }).waitFor(); + await ready(); + const staleToken = lastInput(); assertEquals( - JSON.parse(posts.at(-1)!.body).expected_current.generation, - "17", + staleToken.expected_current, + owner(), + "same generation, stale token", ); - await capture("old-generation-rejected"); + assert(staleToken.expected_observation_token !== observationToken()); + await capture("old-token-rejected", [ + "intentional dedicated HTTP409 not_accepted", + ]); + await freshStop(staleToken); + await capture("token-fresh-explicit"); + + // 旧操作は作用済みかもしれない。supersededは現在への作用失効であり過去の結果ではない。 + action = "transport-lost"; + await button("Stop conversation body").click(); + await panel().getByRole("alert").filter({ + hasText: /Request outcome unknown\./, + }).waitFor(); + await ready(); + const lostStop = posts.at(-1)!.body; + await retainedAfterReadAndReload("stop"); + await capture("lost-stop-retained", [ + "intentional mutation response loss", + ]); + generation = String(Number(generation) + 1); + outcome = "superseded"; + releaseBody = holdBodyRead(); + await button("Check stop outcome").click(); + await panel().getByRole("alert").filter({ + hasText: /Request superseded\./, + }).waitFor(); + assertEquals( + posts.at(-1)!.body, + lostStop, + "outcome uses the frozen stop input", + ); + const supersessionText = await panel().innerText(); + assert( + supersessionText.includes( + "This does not determine whether an earlier operation took effect.", + ), + ); + assert(!supersessionText.includes("Historical stop confirmed.")); + assert(!supersessionText.includes("Stop failed")); + assert(await button("Stop conversation body").isDisabled()); + await capture("outcome-superseded-awaiting-get", [ + "intentional dedicated HTTP409 superseded", + ]); + releaseBody(); + await ready(); + const beforeSupersededReload = posts.length; + await page.reload(); + await ready(); + assertEquals(await button("Retry same stop request").count(), 0); + assertEquals(posts.length, beforeSupersededReload); + await freshStop(JSON.parse(lostStop)); + await capture("superseded-reload-fresh-explicit"); + outcome = "pending"; + + // 並行absent-startで別要求が本体を確保した場面を模擬する。 + observation = "absent"; + await refresh(); + action = "absent-race"; + await button("Start conversation body").click(); + await panel().getByRole("alert").filter({ + hasText: /Request superseded\./, + }).waitFor(); + await ready(); + const absentRace = lastInput(); + assertEquals(absentRace.expected_current, null); + assertEquals(absentRace.expected_observation_token, null); + assertEquals(await button("Start conversation body").count(), 0); + await freshStop(absentRace); + await capture("absent-start-race-fresh-explicit", [ + "synthetic concurrent owner HTTP409 superseded", + ]); + + // busyは明示的にunresolvedであり、409を一律terminalとしない。 + observation = "stopped"; + await refresh(); + action = "busy"; + await button("Restart conversation body").click(); + await panel().getByRole("alert").filter({ + hasText: /Request outcome unknown\./, + }).waitFor(); + await ready(); + const busyStart = posts.at(-1)!.body; + await retainedAfterReadAndReload("start"); + await capture("busy-retained", ["intentional HTTP409 unresolved"]); + action = "accepted"; + await button("Retry same start request").click(); + await panel().getByText("Request accepted.", { exact: true }).waitFor(); + await ready(); + assertEquals(posts.at(-1)!.body, busyStart); + await capture("busy-exact-retry"); + + // 本番HTTP parserでstatus・identity・JSONの拒否を検査する。 + // 正常fixtureは生成DTOに従い、不正JSONはunknownとして保持する。 + const retainingFaults: Action[] = [ + "generic-conflict", + "input-conflict", + "unauthorized", + "wrong-workspace", + "wrong-subject", + "wrong-request", + "extra-request-field", + "extra-error-field", + "invalid-resolution", + ]; + for (const fault of retainingFaults) { + action = fault; + await button("Stop conversation body").click(); + await panel().getByRole("alert").filter({ + hasText: /Request outcome unknown\./, + }).waitFor(); + await ready(); + const frozen = posts.at(-1)!.body; + await retainedAfterReadAndReload("stop"); + // 非terminal応答の明示再送でも同じ入力を保持する。 + await button("Retry same stop request").click(); + await panel().getByRole("alert").filter({ + hasText: /Request outcome unknown\./, + }).waitFor(); + await ready(); + assertEquals(posts.at(-1)!.body, frozen); + assertEquals(await button("Stop conversation body").count(), 0); + await capture(`${fault}-retained`, [ + `intentional ${fault}; no terminal authority`, + ]); + action = "accepted"; + await button("Retry same stop request").click(); + await panel().getByText("Request accepted.", { exact: true }) + .waitFor(); + await ready(); + assertEquals( + posts.at(-1)!.body, + frozen, + "reconciliation never substitutes new input", + ); + } permission = false; await refresh(); assertEquals(await button("Stop conversation body").count(), 0); @@ -452,24 +825,46 @@ try { await capture("get-failure", [ "intentional 503 body GET; browser console reports failed resource", ]); - const unexpected = errors.filter((e: any) => { + const actualHttp = errors.flatMap((e) => + e.kind === "http" + ? [{ status: e.status, path: new URL(e.url).pathname }] + : [] + ); + const httpKey = (e: { status: number; path: string }) => + `${e.status} ${e.path}`; + assertEquals( + actualHttp.map(httpKey).sort(), + expectedHttp.map(httpKey).sort(), + "every HTTP error must match an injected status/path (including count)", + ); + const lostRequests = errors.flatMap((e) => + e.kind === "request" && e.message === "net::ERR_FAILED" + ? [new URL(e.url).pathname] + : [] + ); + assertEquals(lostRequests.sort(), [...expectedLost].sort()); + const unexpected = errors.filter((e) => { if (e.kind === "request") { - return !(e.message === "net::ERR_ABORTED" && - /^\/api\/w\/(memory-review|other-review)\/workers$/.test( - new URL(e.url).pathname, - )); - } - if (e.kind === "http") { - return e.status !== 503 || !e.url.endsWith("/body"); + const path = new URL(e.url).pathname; + return !((e.message === "net::ERR_ABORTED" && + /^\/api\/w\/(memory-review|other-review)\/workers$/.test(path)) || + (e.message === "net::ERR_FAILED" && expectedLost.includes(path))); } + if (e.kind === "http") return false; // statusとpathの正確な件数は上で検査する。 if (e.kind === "console") { - return !e.message.includes("503 (Service Unavailable)"); + const status = e.message.match( + /^Failed to load resource: the server responded with a status of (401|409|503)\b/, + ); + return !(status && + expectedHttp.some((h) => h.status === Number(status[1]))) && + !(e.message === "Failed to load resource: net::ERR_FAILED" && + expectedLost.length > 0); } return true; }); assertEquals(unexpected, [], "unexpected browser diagnostics"); manifest.assertions.push( - `${prefix}: normal entry, keyboard focus/activation, missing session.record, unknown GET/reload/exact retry, pending stop, confirmed stop/restart, old generation, read-only, unavailable owner, account, Workspace and Subject isolation, GET failure`, + `${prefix}: synthetic HTTP only; normal entry, keyboard focus/activation, missing session.record, unknown GET/reload/exact retry, pending stop, confirmed stop/restart; dedicated 409 generation/absent-start superseded and token not_accepted with GET-gated fresh explicit operations; lost stop -> exact outcome superseded -> GET/reload -> fresh operation with distinct historical wording; busy exact retry; generic409/input-conflict/401/mismatched workspace/subject/request/malformed shape retention; read-only, unavailable owner, account, Workspace and Subject isolation, GET failure`, ); } finally { await context.close();