fix(subject-body): settle identity-bound stale lifecycle intents

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