fix(subject-body): settle identity-bound stale lifecycle intents
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user