Merge commit '8018f648e4054bad1881de8c74681604a4fbd66f' into work/t-808-subject-chat

This commit is contained in:
2026-10-31 15:52:44 +09:00
26 changed files with 1522 additions and 164 deletions
+6 -3
View File
@@ -252,6 +252,9 @@ impl_openapi_schema!(
PluginArtifactDeliveryRequest, PluginArtifactDeliveryRequest,
PluginArtifactDeliveryMetadata, PluginArtifactDeliveryMetadata,
PluginWorkerDiagnostics, PluginWorkerDiagnostics,
SubjectBodyApiError,
SubjectBodyFailedRequest,
SubjectBodyRequestResolution,
SubjectBodyResponse, SubjectBodyResponse,
SubjectBodyStartRequest, SubjectBodyStartRequest,
SubjectBodyStopRequest, SubjectBodyStopRequest,
@@ -1564,7 +1567,7 @@ pub trait ServerApi {
#[path] workspace_id: String, #[path] workspace_id: String,
#[path] subject_id: String, #[path] subject_id: String,
#[body] request: SubjectBodyStartRequest, #[body] request: SubjectBodyStartRequest,
) -> Result<SubjectBodyMutationResponse, RepositoryApiError>; ) -> Result<SubjectBodyMutationResponse, SubjectBodyApiError>;
#[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop", status = 200, error_status = 400, #[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)] 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] workspace_id: String,
#[path] subject_id: String, #[path] subject_id: String,
#[body] request: SubjectBodyStopRequest, #[body] request: SubjectBodyStopRequest,
) -> Result<SubjectBodyMutationResponse, RepositoryApiError>; ) -> Result<SubjectBodyMutationResponse, SubjectBodyApiError>;
#[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop/outcome", status = 200, error_status = 400, #[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)] 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] workspace_id: String,
#[path] subject_id: String, #[path] subject_id: String,
#[body] request: SubjectBodyStopRequest, #[body] request: SubjectBodyStopRequest,
) -> Result<SubjectBodyStopOutcomeResponse, RepositoryApiError>; ) -> Result<SubjectBodyStopOutcomeResponse, SubjectBodyApiError>;
#[patch( #[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior", "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
+97 -2
View File
@@ -1,9 +1,66 @@
//! Subject-facing projections and intents over ordinary Worker lifecycle authority. //! Subject-facing projections and intents over ordinary Worker lifecycle authority.
//! No current-body mapping or execution state is owned by these DTOs. //! 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 schemars::JsonSchema;
use serde::{Deserialize, Serialize}; 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<Diagnostic>,
pub request: Option<SubjectBodyFailedRequest>,
#[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<RepositoryApiError> 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -112,6 +169,9 @@ pub fn subject_body_api_typescript() -> String {
use ts_rs::TS; use ts_rs::TS;
let config = ts_rs::Config::default(); let config = ts_rs::Config::default();
let declarations = [ let declarations = [
SubjectBodyRequestResolution::decl(&config),
SubjectBodyFailedRequest::decl(&config),
SubjectBodyApiError::decl(&config),
SubjectBodyRef::decl(&config), SubjectBodyRef::decl(&config),
SubjectBodyObservation::decl(&config), SubjectBodyObservation::decl(&config),
SubjectBodyResponse::decl(&config), SubjectBodyResponse::decl(&config),
@@ -123,7 +183,7 @@ pub fn subject_body_api_typescript() -> String {
SubjectBodyStopOutcomeResponse::decl(&config), SubjectBodyStopOutcomeResponse::decl(&config),
]; ];
format!( 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 declarations
.into_iter() .into_iter()
.map(|declaration| format!("export {declaration}")) .map(|declaration| format!("export {declaration}"))
@@ -137,6 +197,41 @@ mod tests {
use super::*; use super::*;
use serde_json::json; 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::<SubjectBodyApiError>(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] #[test]
fn start_requires_an_explicit_nullable_current_observation() { fn start_requires_an_explicit_nullable_current_observation() {
assert!( assert!(
+3
View File
@@ -12,6 +12,9 @@ pub enum RuntimeError {
#[error("initial worker input must be user input, got {kind}")] #[error("initial worker input must be user input, got {kind}")]
InvalidInitialInputKind { kind: String }, 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")] #[error("worker {worker_id} Restore observation conflicts with the current operation")]
RestoreObservationConflict { worker_id: WorkerId }, RestoreObservationConflict { worker_id: WorkerId },
+23 -6
View File
@@ -2443,6 +2443,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
StatusCode::CONFLICT StatusCode::CONFLICT
} }
RuntimeError::RuntimeStopped RuntimeError::RuntimeStopped
| RuntimeError::RestoreObservationStale { .. }
| RuntimeError::RestoreObservationConflict { .. } | RuntimeError::RestoreObservationConflict { .. }
| RuntimeError::RestoreAdmissionBusy { .. } | RuntimeError::RestoreAdmissionBusy { .. }
| RuntimeError::StopObservationConflict { .. } | RuntimeError::StopObservationConflict { .. }
@@ -2472,6 +2473,7 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
fn code_for_runtime_error(error: &RuntimeError) -> String { fn code_for_runtime_error(error: &RuntimeError) -> String {
match error { match error {
RuntimeError::RuntimeStopped => "runtime_stopped".to_string(), RuntimeError::RuntimeStopped => "runtime_stopped".to_string(),
RuntimeError::RestoreObservationStale { .. } => "restore_observation_stale".to_string(),
RuntimeError::RestoreObservationConflict { .. } => { RuntimeError::RestoreObservationConflict { .. } => {
"restore_observation_conflict".to_string() "restore_observation_conflict".to_string()
} }
@@ -4013,14 +4015,15 @@ mod tests {
request_id: "stale-http-restore".into(), request_id: "stale-http-restore".into(),
preparation: None, preparation: None,
}; };
let response = let coordinate_uri = format!("{restore_uri}/coordinate");
authed_json_request(app.clone(), Method::POST, &restore_uri, token, &stale).await; for uri in [&restore_uri, &coordinate_uri] {
assert_eq!(response.status(), StatusCode::CONFLICT); let response = authed_json_request(app.clone(), Method::POST, uri, token, &stale).await;
let conflict: RuntimeHttpErrorResponse = read_json(response).await; assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(conflict.error.code, "restore_observation_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 mut restore_request = runtime.test_restore_request(&created.worker.worker_ref);
let coordinate_uri = format!("{restore_uri}/coordinate");
let mut coordination = runtime_api::WorkerRestoreCoordinationRequest { let mut coordination = runtime_api::WorkerRestoreCoordinationRequest {
expected_observation_token: restore_request.expected_observation_token.clone(), expected_observation_token: restore_request.expected_observation_token.clone(),
request_id: restore_request.request_id.clone(), request_id: restore_request.request_id.clone(),
@@ -4064,6 +4067,20 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let admitted: runtime_api::WorkerRestoreCoordinationResponse = read_json(response).await; let admitted: runtime_api::WorkerRestoreCoordinationResponse = read_json(response).await;
assert!(admitted.result.is_none() && admitted.preparation.is_some()); 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!( assert_eq!(
runtime runtime
.worker_detail(&created.worker.worker_ref) .worker_detail(&created.worker.worker_ref)
+64 -11
View File
@@ -2246,7 +2246,12 @@ impl Runtime {
if coordinate && request.preparation.is_none() && !recovering_pending { if coordinate && request.preparation.is_none() && !recovering_pending {
if candidate.restore_guard.observation_token if candidate.restore_guard.observation_token
!= request.expected_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() || candidate.has_pending_lifecycle_operation()
{ {
return Err(conflict()); return Err(conflict());
@@ -2264,8 +2269,12 @@ impl Runtime {
)); ));
} }
state.ensure_running()?; state.ensure_running()?;
if candidate.restore_guard.observation_token != request.expected_observation_token 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() && !recovering_pending) || (candidate.has_pending_lifecycle_operation() && !recovering_pending)
{ {
return Err(conflict()); return Err(conflict());
@@ -7960,6 +7969,7 @@ fn runtime_worker_create_failure_fields(
error: &RuntimeError, error: &RuntimeError,
) -> (&'static str, Option<String>, Option<String>) { ) -> (&'static str, Option<String>, Option<String>) {
match error { match error {
RuntimeError::RestoreObservationStale { .. } => ("restore_observation_stale", None, None),
RuntimeError::RestoreObservationConflict { .. } => { RuntimeError::RestoreObservationConflict { .. } => {
("restore_observation_conflict", None, None) ("restore_observation_conflict", None, None)
} }
@@ -12280,7 +12290,7 @@ mod tests {
stopping.join().unwrap().unwrap(); stopping.join().unwrap().unwrap();
assert!(matches!( assert!(matches!(
restoring.join().unwrap(), restoring.join().unwrap(),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!(*backend.stop_count.lock().unwrap(), 1); assert_eq!(*backend.stop_count.lock().unwrap(), 1);
assert_eq!(*backend.restore_count.lock().unwrap(), 0); assert_eq!(*backend.restore_count.lock().unwrap(), 0);
@@ -12572,6 +12582,49 @@ mod tests {
.1 .1
.is_some() .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 let operation_id = runtime
.lock() .lock()
.unwrap() .unwrap()
@@ -12706,7 +12759,7 @@ mod tests {
let before = runtime.worker_detail(&worker.worker_ref).unwrap(); let before = runtime.worker_detail(&worker.worker_ref).unwrap();
assert!(matches!( assert!(matches!(
runtime.restore_worker_operation(&worker.worker_ref, stale), runtime.restore_worker_operation(&worker.worker_ref, stale),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!(runtime.worker_detail(&worker.worker_ref).unwrap(), before); assert_eq!(runtime.worker_detail(&worker.worker_ref).unwrap(), before);
let state = runtime.lock().unwrap(); let state = runtime.lock().unwrap();
@@ -12732,7 +12785,7 @@ mod tests {
runtime.stop_worker(&worker.worker_ref, None).unwrap(); runtime.stop_worker(&worker.worker_ref, None).unwrap();
assert!(matches!( assert!(matches!(
runtime.restore_worker_operation(&worker.worker_ref, other_intent), runtime.restore_worker_operation(&worker.worker_ref, other_intent),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
let stopped = runtime.worker_detail(&worker.worker_ref).unwrap(); let stopped = runtime.worker_detail(&worker.worker_ref).unwrap();
assert_ne!( assert_ne!(
@@ -12786,7 +12839,7 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
runtime.restore_worker_operation(&worker.worker_ref, request), runtime.restore_worker_operation(&worker.worker_ref, request),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!(*backend.restore_count.lock().unwrap(), 0); assert_eq!(*backend.restore_count.lock().unwrap(), 0);
} }
@@ -12824,7 +12877,7 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
second.join().unwrap(), second.join().unwrap(),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!(*backend.restore_count.lock().unwrap(), 1); assert_eq!(*backend.restore_count.lock().unwrap(), 1);
assert!( assert!(
@@ -13158,7 +13211,7 @@ mod tests {
competitor.request_id = "different-intent".into(); competitor.request_id = "different-intent".into();
assert!(matches!( assert!(matches!(
resumed.coordinate_worker_restore_operation(&worker.worker_ref, competitor, None), resumed.coordinate_worker_restore_operation(&worker.worker_ref, competitor, None),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!(*backend.restore_count.lock().unwrap(), count); assert_eq!(*backend.restore_count.lock().unwrap(), count);
*backend.restore_result.lock().unwrap() = None; *backend.restore_result.lock().unwrap() = None;
@@ -13570,7 +13623,7 @@ mod tests {
stale.request_id = "stale-after-restart".into(); stale.request_id = "stale-after-restart".into();
assert!(matches!( assert!(matches!(
restarted.restore_worker_operation(&worker.worker_ref, stale), restarted.restore_worker_operation(&worker.worker_ref, stale),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
assert_eq!( assert_eq!(
restarted.worker_detail(&worker.worker_ref).unwrap().status, restarted.worker_detail(&worker.worker_ref).unwrap().status,
@@ -17040,7 +17093,7 @@ mod tests {
stale.request_id = "new-intent-with-old-token".into(); stale.request_id = "new-intent-with-old-token".into();
assert!(matches!( assert!(matches!(
restarted.restore_worker_operation(&worker.worker_ref, stale), restarted.restore_worker_operation(&worker.worker_ref, stale),
Err(RuntimeError::RestoreObservationConflict { .. }) Err(RuntimeError::RestoreObservationStale { .. })
)); ));
} }
} }
@@ -1231,7 +1231,7 @@ mod publication {
let mut stale = request; let mut stale = request;
stale.request_id = "vertical-new-stale-intent".into(); stale.request_id = "vertical-new-stale-intent".into();
assert!( 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 runtime
.stop_worker_scoped(&scope, &worker_ref, None) .stop_worker_scoped(&scope, &worker_ref, None)
@@ -831,7 +831,7 @@ fn published_terminal_receipt_fault(fault: TerminalFault) {
stale.request_id = "terminal-fresh-stale-intent".into(); stale.request_id = "terminal-fresh-stale-intent".into();
assert!(matches!( assert!(matches!(
runtime.restore_worker_operation_scoped(&scope, &worker_ref, stale), 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_single_publication(&fixture, 1);
assert_eq!( assert_eq!(
+12 -5
View File
@@ -880,14 +880,16 @@ impl RuntimeRegistryError {
code, code,
message, 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 Error::RestoreObservationConflict
} else if matches!( } else if matches!(
code.as_str(), 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) Error::RepositoryConflict(message)
} else { } else {
@@ -6675,6 +6677,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Stop request ID was already used with different input", "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( EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic(
"restore_observation_conflict", "restore_observation_conflict",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
+4
View File
@@ -169,6 +169,10 @@ pub enum Error {
"restore_observation_conflict: Restore observation changed; refresh before a new intent" "restore_observation_conflict: Restore observation changed; refresh before a new intent"
)] )]
RestoreObservationConflict, 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}")] #[error("Repository conflict: {0}")]
RepositoryConflict(String), RepositoryConflict(String),
#[error("Registry inconsistency: {0}")] #[error("Registry inconsistency: {0}")]
+34 -16
View File
@@ -6782,6 +6782,13 @@ impl WorkspaceApi {
}, },
) )
.map_err(RuntimeRegistryError::into_error)?; .map_err(RuntimeRegistryError::into_error)?;
if restored
.diagnostics
.iter()
.any(|d| d.code == "restore_observation_stale")
{
return Err(Error::WorkerLifecycleObservationStale.into());
}
if restored if restored
.diagnostics .diagnostics
.iter() .iter()
@@ -10242,17 +10249,19 @@ impl server_api::ServerApi for ServerApiContractService {
workspace_id: String, workspace_id: String,
subject_id: String, subject_id: String,
request: server_api::SubjectBodyStartRequest, request: server_api::SubjectBodyStartRequest,
) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::SubjectBodyApiError>
{ {
subject_body::start( subject_body::start(
self.workspace_api()?.clone(), self.workspace_api()?.clone(),
context, context,
workspace_id, workspace_id.clone(),
subject_id, subject_id.clone(),
request, request.clone(),
) )
.await .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( async fn subjektiv_subject_body_stop(
@@ -10261,17 +10270,19 @@ impl server_api::ServerApi for ServerApiContractService {
workspace_id: String, workspace_id: String,
subject_id: String, subject_id: String,
request: server_api::SubjectBodyStopRequest, request: server_api::SubjectBodyStopRequest,
) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::RepositoryApiError> ) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::SubjectBodyApiError>
{ {
subject_body::stop( subject_body::stop(
self.workspace_api()?.clone(), self.workspace_api()?.clone(),
context, context,
workspace_id, workspace_id.clone(),
subject_id, subject_id.clone(),
request, request.clone(),
) )
.await .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( async fn subjektiv_subject_body_stop_outcome(
@@ -10282,17 +10293,19 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::SubjectBodyStopRequest, request: server_api::SubjectBodyStopRequest,
) -> std::result::Result< ) -> std::result::Result<
server_api::SubjectBodyStopOutcomeResponse, server_api::SubjectBodyStopOutcomeResponse,
server_api::RepositoryApiError, server_api::SubjectBodyApiError,
> { > {
subject_body::stop_outcome( subject_body::stop_outcome(
self.workspace_api()?.clone(), self.workspace_api()?.clone(),
context, context,
workspace_id, workspace_id.clone(),
subject_id, subject_id.clone(),
request, request.clone(),
) )
.await .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( 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 // A definitive non-admission conflict is not unknown delivery. Retire
// only this pinned intent, without retrying it or exchanging its // only this pinned intent, without retrying it or exchanging its
// observation. A later deliberate invocation may observe anew. // 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.config_store.settle_internal_worker_restore_intent(
api.workspace_id(), api.workspace_id(),
worker, worker,
@@ -39169,6 +39185,8 @@ fn api_error_status(error: &Error) -> StatusCode {
| Error::RuntimeBindingIdConflict { .. } | Error::RuntimeBindingIdConflict { .. }
| Error::RuntimeBindingFingerprintConflict { .. } | Error::RuntimeBindingFingerprintConflict { .. }
| Error::RepositoryConflict(_) | Error::RepositoryConflict(_)
| Error::SubjectBodySuperseded
| Error::WorkerLifecycleObservationStale
| Error::RestoreObservationConflict => StatusCode::CONFLICT, | Error::RestoreObservationConflict => StatusCode::CONFLICT,
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
Error::InvalidInput(message) Error::InvalidInput(message)
@@ -6,6 +6,28 @@ use server_api::{
SubjectBodyResponse, SubjectBodyStartRequest, SubjectBodyStopRequest, 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 { fn diagnostic(code: &str, message: String) -> server_api::Diagnostic {
server_api::Diagnostic { server_api::Diagnostic {
code: code.into(), code: code.into(),
@@ -61,10 +83,7 @@ pub(super) fn require_expected(
) -> ApiResult<Option<crate::store::WorkerSingletonLease>> { ) -> ApiResult<Option<crate::store::WorkerSingletonLease>> {
let lease = current(api, subject)?; let lease = current(api, subject)?;
if lease.as_ref().map(reference).as_ref() != expected { if lease.as_ref().map(reference).as_ref() != expected {
return Err(Error::RepositoryConflict( return Err(Error::SubjectBodySuperseded.into());
"Subject body changed; reload before a new lifecycle intent".into(),
)
.into());
} }
Ok(lease) Ok(lease)
} }
@@ -222,11 +241,8 @@ pub(super) async fn stop_outcome(
.store .store
.db_executor() .db_executor()
.run(move || { .run(move || {
require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| { require_expected(&lookup_api, &lookup_subject, Some(&expected))?
ApiError::from(Error::RepositoryConflict( .ok_or_else(|| ApiError::from(Error::SubjectBodySuperseded))
"Subject has no current body".into(),
))
})
}) })
.await?; .await?;
let worker = let worker =
@@ -271,11 +287,8 @@ pub(super) async fn stop(
.store .store
.db_executor() .db_executor()
.run(move || { .run(move || {
require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| { require_expected(&lookup_api, &lookup_subject, Some(&expected))?
ApiError::from(Error::RepositoryConflict( .ok_or_else(|| ApiError::from(Error::SubjectBodySuperseded))
"Subject has no current body".into(),
))
})
}) })
.await?; .await?;
let worker = let worker =
@@ -301,7 +314,9 @@ pub(super) async fn stop(
Err(error) Err(error)
if matches!( if matches!(
error.error, error.error,
Error::RepositoryConflict(_) Error::SubjectBodySuperseded
| Error::WorkerLifecycleObservationStale
| Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_) | Error::WorkspacePermissionDenied(_)
| Error::RestoreObservationConflict | Error::RestoreObservationConflict
) => ) =>
@@ -344,9 +359,7 @@ pub(super) async fn start(
) )
.await?; .await?;
if observed.current.as_ref() != Some(expected) { if observed.current.as_ref() != Some(expected) {
return Err( return Err(Error::SubjectBodySuperseded.into());
Error::RepositoryConflict("Subject body changed before start".into()).into(),
);
} }
let SubjectBodyObservation::Available { .. } = observed.observation else { let SubjectBodyObservation::Available { .. } = observed.observation else {
return Err(Error::RepositoryConflict( return Err(Error::RepositoryConflict(
@@ -392,7 +405,9 @@ pub(super) async fn start(
Err(error) Err(error)
if matches!( if matches!(
error.error, error.error,
Error::RepositoryConflict(_) Error::SubjectBodySuperseded
| Error::WorkerLifecycleObservationStale
| Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_) | Error::WorkspacePermissionDenied(_)
| Error::RestoreObservationConflict | Error::RestoreObservationConflict
) => ) =>
@@ -426,6 +441,7 @@ pub(super) async fn start(
let create_workspace = workspace.clone(); let create_workspace = workspace.clone();
let request_id = request.request_id.clone(); let request_id = request.request_id.clone();
let operation = operation_id(&account, &workspace, &subject, &request_id); 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 result = run_host_workflow(api.host_executors.host()?, async move {
let subject_record = require_subject(&create_api, &create_subject)?; let subject_record = require_subject(&create_api, &create_subject)?;
// An exact creation retry uses the same ordinary reservation, including its // An exact creation retry uses the same ordinary reservation, including its
@@ -496,11 +512,35 @@ pub(super) async fn start(
Err(error) Err(error)
if matches!( if matches!(
error.error, error.error,
Error::RepositoryConflict(_) Error::SubjectBodySuperseded
| Error::WorkerLifecycleObservationStale
| Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_) | Error::WorkspacePermissionDenied(_)
| Error::SubjektivSubjectNotFound(_) | 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<bool> {
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); return Err(error);
} }
Err(error) => ( Err(error) => (
@@ -11,6 +11,7 @@
//! `tests/support/restore_environment.rs` before any fixture starts. This module //! `tests/support/restore_environment.rs` before any fixture starts. This module
//! never sets process environment, installs tools, or contacts a live model. //! never sets process environment, installs tools, or contacts a live model.
//! Resource races and Workdir rebinding belong to the separate lifecycle suite. //! Resource races and Workdir rebinding belong to the separate lifecycle suite.
use super::subject_body_tests::assert_failed_request;
use super::*; use super::*;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
@@ -460,6 +461,7 @@ async fn body_same_request_concurrent_start_stop_outcome_and_restore_preserve_su
StatusCode::CONFLICT, StatusCode::CONFLICT,
"old Runtime observation: {result}" "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. // 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"), .any(|(status, body)| *status == StatusCode::OK && body["disposition"] == "accepted"),
"one request must start the body: {a:?}, {b:?}" "one request must start the body: {a:?}, {b:?}"
); );
for (status, body) in [&a, &b] { assert_eq!(
assert!( [&a, &b]
matches!(*status, StatusCode::OK | StatusCode::CONFLICT), .iter()
"{status}: {body}" .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); assert_eq!(f.worker_count(), 1);
let current = f.read(&subject).await; 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, StatusCode::CONFLICT,
"cross-Subject {suffix}: {result}" "cross-Subject {suffix}: {result}"
); );
assert_failed_request(&result, &other, "body-foreign-intent", "superseded");
} }
let mut old_generation = foreign.clone(); let mut old_generation = foreign.clone();
old_generation["expected_current"]["generation"] = json!( 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, StatusCode::CONFLICT,
"stale generation {suffix}: {result}" "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"); let reader = seed_test_api_token(f.connected.api.store.as_ref(), "connected-body-reader");
for (method, suffix, body) in [ for (method, suffix, body) in [
@@ -603,6 +620,10 @@ async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targe
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"non-owner {suffix}: {result}" "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 wrong_workspace = body_path(&subject).replace(TEST_WORKSPACE_ID, "not-this-workspace");
let (status, result) = f let (status, result) = f
@@ -704,3 +725,126 @@ async fn body_start_and_restore_do_not_repair_missing_session_record_permission(
"stopped" "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();
}
@@ -4,6 +4,27 @@
//! by the connected suites, not inferred from this projection/admission fixture. //! by the connected suites, not inferred from this projection/admission fixture.
use super::*; 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::<server_api::SubjectBodyApiError>(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 { struct BodyFixture {
api: WorkspaceApi, api: WorkspaceApi,
owner: String, owner: String,
@@ -120,6 +141,10 @@ async fn body_operations_reject_nonowners_and_cross_workspace_targets() {
) )
.await; .await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); 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 path = f.path().replace(TEST_WORKSPACE_ID, "another-workspace");
let (status, body) = response(&f.api, human_request(&f.owner, "GET", &path, None)).await; 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, &f.owner,
"POST", "POST",
&format!("{}{suffix}", f.path()), &format!("{}{suffix}", f.path()),
Some(payload), Some(payload.clone()),
), ),
) )
.await; .await;
assert_eq!(status, StatusCode::CONFLICT, "{body}"); assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert_failed_request(
&body,
&f.subject,
payload["request_id"].as_str().unwrap(),
"superseded",
);
} }
assert_eq!( assert_eq!(
f.read().await["current"], 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()); 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}"
);
}
}
@@ -626,12 +626,18 @@ impl WorkspaceWorker {
.runtime .runtime
.stop_worker(&worker.identity, request) .stop_worker(&worker.identity, request)
.map_err(RuntimeRegistryError::into_error)?; .map_err(RuntimeRegistryError::into_error)?;
if let Some(conflict) = result.diagnostics.iter().find(|diagnostic| { if result
matches!( .diagnostics
diagnostic.code.as_str(), .iter()
"stop_observation_conflict" | "stop_request_conflict" .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()); return Err(Error::RepositoryConflict(conflict.message.clone()).into());
} }
if result.state != InternalWorkerOperationState::Accepted || result.replayed { if result.state != InternalWorkerOperationState::Accepted || result.replayed {
+87 -20
View File
@@ -14799,6 +14799,40 @@
], ],
"type": "object" "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": { "SubjectBodyDisposition": {
"enum": [ "enum": [
"accepted", "accepted",
@@ -14807,6 +14841,30 @@
], ],
"type": "string" "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": { "SubjectBodyMutationResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -14917,6 +14975,15 @@
], ],
"type": "object" "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": { "SubjectBodyResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -23026,7 +23093,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a" "x-yoi-source-digest": "sha256:83296655378ab00d5b7d4ab344879bbae5069cec5853407c44ee0d562449134a"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -42368,7 +42435,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42378,7 +42445,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42388,7 +42455,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42398,7 +42465,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42408,7 +42475,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42418,7 +42485,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42485,7 +42552,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42495,7 +42562,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42505,7 +42572,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42515,7 +42582,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42525,7 +42592,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42535,7 +42602,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42602,7 +42669,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42612,7 +42679,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42622,7 +42689,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42632,7 +42699,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42642,7 +42709,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42652,7 +42719,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -42662,7 +42729,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/RepositoryApiError" "$ref": "#/components/schemas/SubjectBodyApiError"
} }
} }
}, },
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,7 +1,26 @@
// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`. // Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.
// Do not edit manually. // 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<Diagnostic>;
request: SubjectBodyFailedRequest | null;
};
export type SubjectBodyRef = { export type SubjectBodyRef = {
runtime_id: string; runtime_id: string;
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -9,6 +9,10 @@ import {
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { tick } from "svelte"; import { tick } from "svelte";
import SubjectBody from "./SubjectBody.svelte"; import SubjectBody from "./SubjectBody.svelte";
import type {
SubjectBodyApiError as BodyErrorResponse,
SubjectBodyFailedRequest,
} from "#lib/generated/subject-body-api.ts";
import { import {
readSubjectBodyAttempt, readSubjectBodyAttempt,
subjectBodyStorageKey, 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) { function savedStop(accountId = props.accountId) {
return { return {
accountId, 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 = available("idle", "generation-18-token");
observation.current = { ...owner, generation: "18" }; observation.current = { ...owner, generation: "18" };
return Response.json({ return Response.json({
request_id: request.request_id, error: "Conflict",
disposition: "rejected", message: "The current body generation changed",
diagnostics: [{ diagnostics: [{
code: "stale_owner", code: "stale_owner",
severity: "warning", severity: "warning",
message: message:
"The current body generation changed. Refresh before acting.", "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); render(SubjectBody, props);
await fireEvent.click( await fireEvent.click(
await screen.findByRole("button", { name: "Stop conversation body" }), await screen.findByRole("button", { name: "Stop conversation body" }),
); );
await screen.findByText("Request rejected."); await screen.findByText(/^Request superseded\./);
await screen.findByText( 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(() => await waitFor(() =>
expect( expect(
(screen.getByRole("button", { (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.expected_observation_token).toBe("after-confirmed-stop");
expect(request.request_id).not.toBe(JSON.parse(posts[0].body).request_id); 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,
);
});
@@ -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<void> { async function submit(action?: 'start' | 'stop'): Promise<void> {
if (!visible || sending || checkingOutcome || loading || storageError || !fresh || !snapshot) return; if (!visible || sending || checkingOutcome || loading || storageError || !fresh || !snapshot) return;
if (attempt ? !retryAuthorized : (!action || !controls[action])) return; if (attempt ? !retryAuthorized : (!action || !controls[action])) return;
@@ -132,7 +150,7 @@
} }
} catch (cause) { } catch (cause) {
if (!current(generation, account, workspace, subject)) return; 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 { } finally {
if (current(generation, account, workspace, subject)) { if (current(generation, account, workspace, subject)) {
sending = false; sending = false;
@@ -170,7 +188,9 @@
void refresh(); void refresh();
} catch (cause) { } catch (cause) {
if (!current(generation, account, workspace, subject)) return; 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 { } finally {
if (current(generation, account, workspace, subject)) checkingOutcome = false; if (current(generation, account, workspace, subject)) checkingOutcome = false;
} }
@@ -10,6 +10,7 @@ import {
saveSubjectBodyAttempt, saveSubjectBodyAttempt,
startSubjectBody, startSubjectBody,
stopSubjectBody, stopSubjectBody,
SubjectBodyApiError,
subjectBodyControls, subjectBodyControls,
subjectBodyStorageKey, subjectBodyStorageKey,
} from "./subject-body.ts"; } 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") 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);
});
@@ -1,4 +1,5 @@
import type { import type {
SubjectBodyFailedRequest,
SubjectBodyMutationResponse, SubjectBodyMutationResponse,
SubjectBodyObservation, SubjectBodyObservation,
SubjectBodyRef, 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<unknown> { 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<unknown> {
if (!response.ok) { if (!response.ok) {
// 生のerror bodyを表示せず、公開Diagnosticだけを境界で検査する。 // GETやHTTP statusだけを、保存済み依頼の決済根拠にしない。
let detail = ""; let detail = "";
let request: SubjectBodyFailedRequest | null = null;
try { try {
const error = record( const error = record(
await readBoundedJson(response, MAX_RESPONSE_BYTES), await readBoundedJson(response, MAX_RESPONSE_BYTES),
["error", "message", "diagnostics"], ["error", "message", "diagnostics", "request"],
); );
if (error.diagnostics !== undefined) { text(error.error);
detail = diagnostics(error.diagnostics).map((item) => item.message) const message = text(error.message, 65536);
.join("; ").slice(0, 1024); 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( throw new SubjectBodyApiError(
`Subject body request failed with HTTP ${response.status}${ `Subject body request failed with HTTP ${response.status}${
detail ? `: ${detail}` : "" detail ? `: ${detail}` : ""
}`, }`,
request,
); );
} }
return readBoundedJson(response, MAX_RESPONSE_BYTES); return readBoundedJson(response, MAX_RESPONSE_BYTES);
@@ -280,7 +341,7 @@ async function mutateSubjectBody(
}, },
); );
return parseSubjectBodyMutationResponse( return parseSubjectBodyMutationResponse(
await checkedJson(response), await checkedJson(response, { workspaceId, subjectId, requestId }),
workspaceId, workspaceId,
subjectId, subjectId,
requestId, requestId,
@@ -324,7 +385,7 @@ export async function getSubjectBodyStopOutcome(
); );
// 認可拒否・通信失敗・不正な応答をnot_foundに読み替えない。 // 認可拒否・通信失敗・不正な応答をnot_foundに読み替えない。
return parseSubjectBodyStopOutcomeResponse( return parseSubjectBodyStopOutcomeResponse(
await checkedJson(response), await checkedJson(response, { workspaceId, subjectId, requestId }),
workspaceId, workspaceId,
subjectId, subjectId,
requestId, requestId,
+438 -43
View File
@@ -3,6 +3,16 @@
import { assert, assertEquals } from "@std/assert"; import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, resolve } from "@std/path"; import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { chromium } from "playwright"; 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 { sha256File } from "../../../tools/web-ux/src/artifacts.ts";
import { import {
startOwnedProcesses, startOwnedProcesses,
@@ -16,7 +26,9 @@ import {
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../.."); const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const run = Deno.args[0] ?? "review"; const run = Deno.args[0] ?? "review";
if (!/^[\w-]+$/.test(run)) throw new Error("safe unique run id required"); 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. await Deno.mkdir(output); // Never replace previous evidence.
const buildRoot = join(root, "web/workspace/build"); const buildRoot = join(root, "web/workspace/build");
await Deno.stat(join(buildRoot, "index.html")); await Deno.stat(join(buildRoot, "index.html"));
@@ -42,7 +54,11 @@ const manifest = {
expectedFaults: [ expectedFaults: [
"structured session.record rejection", "structured session.record rejection",
"unknown start/stop receipts", "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", "503 body GET",
"navigation teardown aborts GET /workers refresh", "navigation teardown aborts GET /workers refresh",
], ],
@@ -95,16 +111,41 @@ try {
"absent"; "absent";
let permission = true; let permission = true;
let getFailure = false; let getFailure = false;
let action: "reject" | "unknown" | "accepted" | "stale" = "reject"; type Action =
let outcome: "pending" | "stopped" = "pending"; | "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 generation = "17";
let tokenChanged = false;
let heldBodyRead: Promise<void> | null = null;
const expectedHttp: { status: number; path: string }[] = [];
const expectedLost: string[] = [];
const posts: { path: string; body: string }[] = []; const posts: { path: string; body: string }[] = [];
const owner = () => ({ const owner = (): SubjectBodyRef => ({
runtime_id: "runtime", runtime_id: "runtime",
worker_id: "worker", worker_id: "worker",
generation, generation,
}); });
function body(workspace: string, subject: string) { const observationToken = () =>
`token-${generation}-${observation}${tokenChanged ? "-fresh" : ""}`;
function body(workspace: string, subject: string): SubjectBodyResponse {
return { return {
workspace_id: workspace, workspace_id: workspace,
subject_id: subject, subject_id: subject,
@@ -142,8 +183,7 @@ try {
worker_state: observation === "stopped" worker_state: observation === "stopped"
? null ? null
: { last_command_id: 1, state: { kind: "idle" } }, : { last_command_id: 1, state: { kind: "idle" } },
restore_observation_token: restore_observation_token: observationToken(),
`token-${generation}-${observation}`,
}, },
}, },
}; };
@@ -189,6 +229,8 @@ try {
/\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/body/, /\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/body/,
)!; )!;
if (request.method() === "GET") { if (request.method() === "GET") {
if (heldBodyRead) await heldBodyRead;
if (getFailure) expectedHttp.push({ status: 503, path });
await route.fulfill({ await route.fulfill({
status: getFailure ? 503 : 200, status: getFailure ? 503 : 200,
json: getFailure json: getFailure
@@ -199,24 +241,142 @@ try {
} }
const raw = request.postData()!; const raw = request.postData()!;
posts.push({ path, body: raw }); posts.push({ path, body: raw });
const input = JSON.parse(raw); const input: SubjectBodyStartRequest | SubjectBodyStopRequest = JSON
.parse(raw);
assert(!("accountId" in input)); 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")) { if (path.endsWith("/outcome")) {
await route.fulfill({ if (outcome === "superseded") {
json: { request_id: input.request_id, outcome, body: null }, 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; return;
} }
if (action === "stale") generation = "18";
if (action === "accepted" && path.endsWith("/start")) { if (action === "accepted" && path.endsWith("/start")) {
observation = "idle"; observation = "idle";
} }
await route.fulfill({ await route.fulfill({
json: { json: {
request_id: input.request_id, request_id: input.request_id,
disposition: action === "reject" || action === "stale" disposition: action === "reject" ? "rejected" : action,
? "rejected"
: action,
diagnostics: action === "reject" diagnostics: action === "reject"
? [{ ? [{
code: "permission_missing", code: "permission_missing",
@@ -224,21 +384,18 @@ try {
message: message:
"Required permission session.record is missing. Ask an administrator to review the default profile; no permissions were added.", "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), body: body(workspace, subject),
}, } satisfies SubjectBodyMutationResponse,
}); });
}, },
); );
const page = await context.newPage(); 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; let documentStatus: number | null = null;
page.on( page.on(
"pageerror", "pageerror",
@@ -277,6 +434,61 @@ try {
await button("Refresh body state").click(); await button("Refresh body state").click();
await ready(); await ready();
} }
function holdBodyRead() {
let release!: () => void;
heldBodyRead = new Promise<void>((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[] = []) { async function capture(name: string, expectedErrors: string[] = []) {
await panel().scrollIntoViewIfNeeded(); await panel().scrollIntoViewIfNeeded();
const id = `${prefix}-${name}`; const id = `${prefix}-${name}`;
@@ -300,6 +512,8 @@ try {
bodyText: await panel().innerText(), bodyText: await panel().innerText(),
errors: [...errors], errors: [...errors],
expectedErrors, expectedErrors,
expectedHttp: [...expectedHttp],
expectedLost: [...expectedLost],
image, image,
imageSha256: await sha256File(image), imageSha256: await sha256File(image),
panelImage, panelImage,
@@ -379,18 +593,177 @@ try {
assertEquals(restart.expected_current.generation, "17"); assertEquals(restart.expected_current.generation, "17");
assert(restart.request_id !== JSON.parse(stopInput).request_id); assert(restart.request_id !== JSON.parse(stopInput).request_id);
await capture("restarted-running"); await capture("restarted-running");
action = "stale"; // 実契約の専用409で決済しても、独立した本体GETが終わるまで新操作は許可しない。
action = "stale-generation";
let releaseBody = holdBodyRead();
await button("Stop conversation body").click(); await button("Stop conversation body").click();
await panel().getByText( await panel().getByRole("alert").filter({
"The current body generation changed. Refresh before acting.", hasText: /Request superseded\./,
{ exact: true }, }).waitFor();
).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(); 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( assertEquals(
JSON.parse(posts.at(-1)!.body).expected_current.generation, staleToken.expected_current,
"17", 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; permission = false;
await refresh(); await refresh();
assertEquals(await button("Stop conversation body").count(), 0); assertEquals(await button("Stop conversation body").count(), 0);
@@ -452,24 +825,46 @@ try {
await capture("get-failure", [ await capture("get-failure", [
"intentional 503 body GET; browser console reports failed resource", "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") { if (e.kind === "request") {
return !(e.message === "net::ERR_ABORTED" && const path = new URL(e.url).pathname;
/^\/api\/w\/(memory-review|other-review)\/workers$/.test( return !((e.message === "net::ERR_ABORTED" &&
new URL(e.url).pathname, /^\/api\/w\/(memory-review|other-review)\/workers$/.test(path)) ||
)); (e.message === "net::ERR_FAILED" && expectedLost.includes(path)));
}
if (e.kind === "http") {
return e.status !== 503 || !e.url.endsWith("/body");
} }
if (e.kind === "http") return false; // statusとpathの正確な件数は上で検査する。
if (e.kind === "console") { 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; return true;
}); });
assertEquals(unexpected, [], "unexpected browser diagnostics"); assertEquals(unexpected, [], "unexpected browser diagnostics");
manifest.assertions.push( 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 { } finally {
await context.close(); await context.close();