feat: publish connected Subject body lifecycle candidate and validation fixes

This commit is contained in:
2026-10-31 14:38:17 +09:00
parent 7b594ded0d
commit 82616c2a41
24 changed files with 3197 additions and 88 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ pub fn subject_body_api_typescript() -> String {
SubjectBodyStopOutcomeResponse::decl(&config), SubjectBodyStopOutcomeResponse::decl(&config),
]; ];
format!( format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.\n// Do not edit manually.\n\nimport type {{ Diagnostic, WorkerSummary }} from './worker-launch-api';\n\n{}\n", "// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.\n// Do not edit manually.\n\nimport type {{ Diagnostic, WorkerSummary }} from \"./worker-launch-api\";\n\n{}\n",
declarations declarations
.into_iter() .into_iter()
.map(|declaration| format!("export {declaration}")) .map(|declaration| format!("export {declaration}"))
+4 -1
View File
@@ -3567,7 +3567,10 @@ impl Runtime {
if matches!(&method, Method::Shutdown { .. }) { if matches!(&method, Method::Shutdown { .. }) {
self.stop_worker_with_scope( self.stop_worker_with_scope(
worker_ref, worker_ref,
Some("worker protocol shutdown".to_string()), runtime_api::WorkerLifecycleRequest {
reason: Some("worker protocol shutdown".to_string()),
stop_intent: None,
},
scope, scope,
)?; )?;
return Ok(Vec::new()); return Ok(Vec::new());
+16 -8
View File
@@ -1208,7 +1208,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
Err(diagnostic( Err(diagnostic(
"worker_stop_admission_unsupported", "worker_stop_admission_unsupported",
HostDiagnosticSeverity::Error, HostDiagnosticSeverity::Error,
"Runtime does not support fenced stop admission".into(), "Runtime does not support fenced stop admission",
)) ))
} }
@@ -1220,7 +1220,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
Err(diagnostic( Err(diagnostic(
"worker_stop_outcome_unsupported", "worker_stop_outcome_unsupported",
HostDiagnosticSeverity::Error, HostDiagnosticSeverity::Error,
"Runtime does not support stop outcome queries".into(), "Runtime does not support stop outcome queries",
)) ))
} }
@@ -3304,7 +3304,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
diagnostic( diagnostic(
"embedded_worker_id_invalid", "embedded_worker_id_invalid",
HostDiagnosticSeverity::Error, HostDiagnosticSeverity::Error,
"Worker ID could not be resolved".into(), "Worker ID could not be resolved",
) )
})?; })?;
let outcome = self let outcome = self
@@ -3329,7 +3329,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
diagnostic( diagnostic(
"embedded_worker_id_invalid", "embedded_worker_id_invalid",
HostDiagnosticSeverity::Error, HostDiagnosticSeverity::Error,
"Worker ID could not be resolved".into(), "Worker ID could not be resolved",
) )
})?; })?;
let outcome = self let outcome = self
@@ -4456,6 +4456,8 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
return "workers:input"; return "workers:input";
} }
if path.ends_with("/stop") if path.ends_with("/stop")
|| path.ends_with("/stop/admit")
|| path.ends_with("/stop/outcome")
|| path.ends_with("/cancel") || path.ends_with("/cancel")
|| (path == "/v1/subject-release" && method == "POST") || (path == "/v1/subject-release" && method == "POST")
{ {
@@ -6656,22 +6658,22 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic( EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic(
"restore_admission_busy", "restore_admission_busy",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Worker lifecycle admission is busy; retry the same Restore intent".into(), "Worker lifecycle admission is busy; retry the same Restore intent",
), ),
EmbeddedRuntimeError::StopAdmissionBusy { .. } => diagnostic( EmbeddedRuntimeError::StopAdmissionBusy { .. } => diagnostic(
"stop_admission_busy", "stop_admission_busy",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Worker lifecycle admission is busy; retry the same stop intent".into(), "Worker lifecycle admission is busy; retry the same stop intent",
), ),
EmbeddedRuntimeError::StopObservationConflict { .. } => diagnostic( EmbeddedRuntimeError::StopObservationConflict { .. } => diagnostic(
"stop_observation_conflict", "stop_observation_conflict",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Stop observation changed; reload before a new intent".into(), "Stop observation changed; reload before a new intent",
), ),
EmbeddedRuntimeError::StopRequestConflict { .. } => diagnostic( EmbeddedRuntimeError::StopRequestConflict { .. } => diagnostic(
"stop_request_conflict", "stop_request_conflict",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Stop request ID was already used with different input".into(), "Stop request ID was already used with different input",
), ),
EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic( EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic(
"restore_observation_conflict", "restore_observation_conflict",
@@ -8810,6 +8812,12 @@ mod tests {
), ),
"workers:read" "workers:read"
); );
for suffix in ["stop", "stop/admit", "stop/outcome"] {
assert_eq!(
workspace_runtime_operation("POST", &format!("/v1/workers/{worker_id}/{suffix}")),
"workers:stop"
);
}
assert_eq!( assert_eq!(
workspace_runtime_operation("POST", "/v1/session-public/material/retention/erase"), workspace_runtime_operation("POST", "/v1/session-public/material/retention/erase"),
"workers:delete" "workers:delete"
+13 -2
View File
@@ -43362,7 +43362,7 @@ mod tests {
let cleanup_lock = current_worker_session_lock(&api, &identity); let cleanup_lock = current_worker_session_lock(&api, &identity);
let held = Arc::new(Mutex::new(None)); let held = Arc::new(Mutex::new(None));
let held_by_callback = held.clone(); let held_by_callback = held.clone();
api.runtime.register_or_replace(WorkdirlessFixtureRuntime { let runtime = WorkdirlessFixtureRuntime {
stop_callback: Some(Arc::new(move || { stop_callback: Some(Arc::new(move || {
// A genuine fresh-ACK path, not historical receipt replay. Capture // A genuine fresh-ACK path, not historical receipt replay. Capture
// must release this fence before Runtime dispatch/callbacks. // must release this fence before Runtime dispatch/callbacks.
@@ -43371,7 +43371,18 @@ mod tests {
notification.notify_one(); notification.notify_one();
})), })),
..Default::default() ..Default::default()
}); };
runtime
.workers
.lock()
.unwrap()
.push(worker_summary_from_registry(
&api.store
.get_worker_registry(TEST_WORKSPACE_ID, &identity)
.unwrap()
.unwrap(),
));
api.runtime.register_or_replace(runtime);
let worker = let worker =
WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
let waiter = tokio::spawn(async move { let waiter = tokio::spawn(async move {
@@ -171,6 +171,12 @@ fn operation_id(account: &str, workspace: &str, subject: &str, request: &str) ->
format!("subject-body-start:{digest}") format!("subject-body-start:{digest}")
} }
fn failure_diagnostics(code: &str, error: ApiError) -> Vec<server_api::Diagnostic> {
let mut diagnostics = vec![diagnostic(code, error.error.to_string())];
diagnostics.extend(error.diagnostics.into_iter().map(Into::into));
diagnostics
}
async fn finish( async fn finish(
api: WorkspaceApi, api: WorkspaceApi,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
@@ -305,10 +311,7 @@ pub(super) async fn stop(
// A post-dispatch failure may have followed a real stop. Keep the exact intent. // A post-dispatch failure may have followed a real stop. Keep the exact intent.
Err(error) => ( Err(error) => (
SubjectBodyDisposition::Unknown, SubjectBodyDisposition::Unknown,
vec![diagnostic( failure_diagnostics("subject_body_stop_outcome_unknown", error),
"subject_body_stop_outcome_unknown",
error.error.to_string(),
)],
), ),
}; };
Ok(finish( Ok(finish(
@@ -398,10 +401,7 @@ pub(super) async fn start(
} }
Err(error) => ( Err(error) => (
SubjectBodyDisposition::Unknown, SubjectBodyDisposition::Unknown,
vec![diagnostic( failure_diagnostics("subject_body_restore_outcome_unknown", error),
"subject_body_restore_outcome_unknown",
error.error.to_string(),
)],
), ),
}; };
return Ok(finish( return Ok(finish(
@@ -470,7 +470,7 @@ pub(super) async fn start(
runtime_id, runtime_id,
display_name: subject_record.role.as_str().into(), display_name: subject_record.role.as_str().into(),
singleton_key: None, singleton_key: None,
profile: Some("builtin:async-chat".into()), profile: Some(server_api::authorization::SUBJECT_CONVERSATION_PROFILE.into()),
ticket_assignment: None, ticket_assignment: None,
initial_submit: vec![], initial_submit: vec![],
workdir_attachments: vec![], workdir_attachments: vec![],
@@ -496,17 +496,16 @@ pub(super) async fn start(
Err(error) Err(error)
if matches!( if matches!(
error.error, error.error,
Error::RepositoryConflict(_) | Error::WorkspacePermissionDenied(_) Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_)
| Error::SubjektivSubjectNotFound(_)
) => ) =>
{ {
return Err(error); return Err(error);
} }
Err(error) => ( Err(error) => (
SubjectBodyDisposition::Unknown, SubjectBodyDisposition::Unknown,
vec![diagnostic( failure_diagnostics("subject_body_start_outcome_unknown", error),
"subject_body_start_outcome_unknown",
error.error.to_string(),
)],
), ),
}; };
Ok(finish( Ok(finish(
@@ -1274,7 +1274,22 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
let worker_ref = RuntimeWorkerRef::new("connected-chat", worker); let worker_ref = RuntimeWorkerRef::new("connected-chat", worker);
let lifecycle_path = let lifecycle_path =
format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}"); format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}");
let stopped = f.post(&format!("{lifecycle_path}/stop"), json!({})).await; let stop_observation = f
.api
.runtime
.worker(&worker_ref)
.unwrap()
.restore_observation_token
.unwrap();
let stopped = f
.post(
&format!("{lifecycle_path}/stop"),
json!({"stop_intent": {
"request_id": "explicit-chat-stop",
"expected_observation_token": stop_observation,
}}),
)
.await;
assert_eq!(stopped["state"], "accepted"); assert_eq!(stopped["state"], "accepted");
let stopped_wire = let stopped_wire =
json!({"operation_id":"connected-stopped","text":"CHAT_STOPPED_SAVED_NOT_NOTIFIED"}); json!({"operation_id":"connected-stopped","text":"CHAT_STOPPED_SAVED_NOT_NOTIFIED"});
@@ -1404,7 +1419,21 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"revocation cannot be repaired by profile or create replay: {body}" "revocation cannot be repaired by profile or create replay: {body}"
); );
f.post(&format!("{lifecycle_path}/stop"), json!({})).await; let stop_observation = f
.api
.runtime
.worker(&worker_ref)
.unwrap()
.restore_observation_token
.unwrap();
f.post(
&format!("{lifecycle_path}/stop"),
json!({"stop_intent": {
"request_id": "explicit-chat-stop-after-revocation",
"expected_observation_token": stop_observation,
}}),
)
.await;
let token = f let token = f
.api .api
.runtime .runtime
@@ -9,6 +9,10 @@ use super::*;
mod chat_delegation_connected; mod chat_delegation_connected;
#[path = "chat_tests.rs"] #[path = "chat_tests.rs"]
mod chat_tests; mod chat_tests;
#[path = "subject_body_cleanup_connected_tests.rs"]
mod subject_body_cleanup_connected;
#[path = "subject_body_connected_tests.rs"]
mod subject_body_connected;
#[path = "subject_body_tests.rs"] #[path = "subject_body_tests.rs"]
mod subject_body_tests; mod subject_body_tests;
#[path = "subject_job_model_supply_tests.rs"] #[path = "subject_job_model_supply_tests.rs"]
@@ -0,0 +1,715 @@
//! Connected stop/restore cleanup regression for the normal Server library target.
//!
//! The entry is authenticated Backend Worker control; Workspace capability signing,
//! Runtime HTTP, FsRuntimeStore, ProfileRuntimeWorkerFactory/Controller, canonical
//! Session restore, Git materialization and RemoteWorkdir sessions are production
//! implementations. Only the external model is scripted (no turn is submitted),
//! and a byte-preserving loopback proxy gates the first stop dispatch and observes
//! the actual Runtime ACK. Neither execution nor restore state is substituted.
//!
//! The test owns the existing Worker session fence after stop's resource snapshot
//! and before its ACK. While ACKed cleanup cannot reacquire that fence, it restores
//! the same ordinary Worker through signed Runtime HTTP, opens real provider
//! sessions/commands, and rebinds the same alias/Workdir through the SQLite lifetime
//! API. Registry insertion and rebind are fixture setup under that fence, not a
//! claim about the browser attachment-management endpoint or its authorization.
//! Provider calls still use the normal signed RemoteWorkdir HTTP boundary; there
//! is no unsigned router, fake ACK, synthetic restore result, or Subject-only
//! execution path. Successful Runtime DELETEs and subsequent provider operations
//! distinguish old resource release from survival of the new lifetimes.
//!
//! Run with the `subject_body_cleanup_connected` library filter. The launching
//! shell/normal runner must supply the isolated restore paths required by
//! tests/support/restore_environment.rs; this module never changes process env.
//! This is an in-process connection/race test, not an OS-crash or live-model test.
//! The command fixture requires the normal POSIX shell and `tail` on PATH.
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(45);
const ALIAS: &str = "workdir";
#[derive(Debug)]
struct RuntimeHttpFailure {
method: Method,
path: String,
status: StatusCode,
code: Option<String>,
message: Option<String>,
closed_before_response: bool,
// Match the shared harness's nested-envelope view without discarding the
// flat Workdir envelope's actual code/message from our diagnostic evidence.
harness_message: String,
}
impl RuntimeHttpFailure {
fn is_expected_creation_probe(&self, workdir: &str) -> bool {
self.method == Method::GET
&& self.path == format!("/v1/working-directories/{workdir}")
&& self.status == StatusCode::NOT_FOUND
&& self.code.as_deref() == Some("working_directory_not_found")
&& self.message.as_deref()
== Some("working_directory_not_found: Runtime working directory was not found")
}
fn is_expected_closed_session(&self) -> bool {
self.method == Method::POST
&& self.path.starts_with("/v1/workdir-sessions/")
&& self.path.ends_with("/operations")
&& self.status == StatusCode::NOT_FOUND
&& self.code.as_deref() == Some("not_found")
&& self.message.as_deref() == Some("Workdir session was not found")
&& self.closed_before_response
}
}
struct StopTransport {
upstream: String,
client: reqwest::Client,
hold_first_stop: AtomicBool,
dispatched: Notify,
release: Semaphore,
acked: Notify,
acks: Mutex<Vec<runtime_api::WorkerLifecycleAck>>,
opened: Mutex<Vec<String>>,
closed: Mutex<Vec<String>>,
failures: Mutex<Vec<RuntimeHttpFailure>>,
}
impl StopTransport {
async fn forward(State(state): State<Arc<Self>>, request: Request<Body>) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let is_stop = request.method() == Method::POST
&& path.starts_with("/v1/workers/")
&& path.ends_with("/stop");
if is_stop && state.hold_first_stop.swap(false, Ordering::SeqCst) {
// stop_impl has already released its pre-dispatch session fence.
state.dispatched.notify_one();
state.release.acquire().await.unwrap().forget();
}
let is_open = request.method() == Method::POST
&& path.starts_with("/v1/working-directories/")
&& path.ends_with("/sessions");
let is_close =
request.method() == Method::DELETE && path.starts_with("/v1/workdir-sessions/");
let (parts, body) = request.into_parts();
let body = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
// Keep method, URI, signed headers and exact body bytes. Runtime's
// production verifier, rather than this proxy, decides authorization.
let response = state
.client
.request(parts.method, format!("{}{}", state.upstream, parts.uri))
.headers(parts.headers)
.body(body)
.send()
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let bytes = response.bytes().await.unwrap();
if status.is_client_error() || status.is_server_error() {
let body: Value = serde_json::from_slice(&bytes).unwrap();
// REST uses {error:{code,message}}, whereas Workdir transport errors
// are {code,message}. Keep only sanitized diagnostic fields, never
// authorization headers, request bodies or raw response bodies.
let error = body.get("error").unwrap_or(&body);
let closed_before_response = path
.strip_suffix("/operations")
.filter(|session_path| session_path.starts_with("/v1/workdir-sessions/"))
.is_some_and(|session_path| {
state
.closed
.lock()
.unwrap()
.iter()
.any(|closed| closed == session_path)
});
state.failures.lock().unwrap().push(RuntimeHttpFailure {
method,
path: path.clone(),
status,
code: error["code"].as_str().map(str::to_owned),
message: error["message"].as_str().map(str::to_owned),
closed_before_response,
harness_message: body["error"]["message"]
.as_str()
.unwrap_or("unknown fixture error")
.to_owned(),
});
}
if is_stop && status.is_success() {
let response: runtime_api::WorkerLifecycleResponse =
serde_json::from_slice(&bytes).unwrap();
state.acks.lock().unwrap().push(response.ack);
// This is a real completed Runtime stop, not merely proxy admission.
state.acked.notify_one();
}
if is_open && status.is_success() {
let response: workdir::http::OpenWorkdirSessionResponse =
serde_json::from_slice(&bytes).unwrap();
state
.opened
.lock()
.unwrap()
.push(response.session_id.as_str().to_owned());
}
if is_close && status == StatusCode::NO_CONTENT {
state.closed.lock().unwrap().push(path);
}
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = status;
*response.headers_mut() = headers;
response
}
fn closed_ids(&self) -> Vec<String> {
let mut ids: Vec<_> = self
.closed
.lock()
.unwrap()
.iter()
.map(|path| {
path.strip_prefix("/v1/workdir-sessions/")
.unwrap()
.to_owned()
})
.collect();
ids.sort();
ids
}
}
struct StopProxy {
transport: Arc<StopTransport>,
_server: ConnectedRuntimeServer,
}
impl Drop for StopProxy {
fn drop(&mut self) {
// A failed assertion must not strand an admitted Host workflow or the
// proxy's graceful shutdown behind the test-only dispatch barrier.
self.transport.release.add_permits(1);
}
}
impl StopProxy {
async fn install(harness: &ConnectedWorkerHarness) -> Self {
let api = &harness.api;
let binding = api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, CONNECTED_RUNTIME)
.await
.unwrap()
.unwrap();
let transport = Arc::new(StopTransport {
upstream: binding.base_url.clone(),
client: reqwest::Client::builder()
.no_proxy()
.timeout(CLEANUP_TIMEOUT)
.build()
.unwrap(),
hold_first_stop: AtomicBool::new(true),
dispatched: Notify::new(),
release: Semaphore::new(0),
acked: Notify::new(),
acks: Mutex::new(Vec::new()),
opened: Mutex::new(Vec::new()),
closed: Mutex::new(Vec::new()),
failures: Mutex::new(Vec::new()),
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let server = ConnectedRuntimeServer::start(
listener,
Router::new()
.fallback(axum::routing::any(StopTransport::forward))
.with_state(transport.clone()),
);
// Retain the real trusted binding and authorization issuer. Only the
// transport address changes, as in workdir_session_close_tests.
api.runtime.register_or_replace(
RemoteWorkerRuntime::new(
RemoteRuntimeConfig {
runtime_id: CONNECTED_RUNTIME.into(),
workspace_id: Some(TEST_WORKSPACE_ID.into()),
display_name: binding.display_name.clone(),
base_url: endpoint,
bearer_token: None,
workspace_authorization: Some(
crate::hosts::WorkspaceRuntimeAuthorization::new(
api.store.clone(),
api.signing_identities.clone(),
harness.backend_url.clone(),
binding,
),
),
strict_public_egress: false,
cached_worker_creation_available: true,
cached_os: "linux".into(),
cached_arch: "x86_64".into(),
cached_status: "active".into(),
timeout: CLEANUP_TIMEOUT,
},
TEST_WORKSPACE_ID.into(),
harness.backend_url.clone(),
)
.unwrap(),
);
Self {
transport,
_server: server,
}
}
async fn open(
&self,
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
workdir: &str,
) -> (WorkdirSessionHandle, String) {
let before = self.transport.opened.lock().unwrap().len();
let session = api
.runtime
.open_workdir_session(&worker.runtime_id, workdir, Some(&worker.worker_id))
.await
.unwrap();
let opened = self.transport.opened.lock().unwrap();
assert_eq!(opened.len(), before + 1, "one actual Runtime session open");
(session, opened[before].clone())
}
}
// The harness owns servers/temporary roots; this guard also stops an active
// restored Controller on assertion failure before those servers are dropped.
struct StopControllerOnDrop {
runtime: worker_runtime::Runtime,
worker: worker_runtime::identity::WorkerRef,
}
impl Drop for StopControllerOnDrop {
fn drop(&mut self) {
let _ = self.runtime.stop_worker(
&self.worker,
Some("cleanup connected stop/restore fixture".into()),
);
}
}
async fn start_held_command(session: &WorkdirSessionHandle) -> CommandHandle {
let command = session
.start_command(workdir::CommandRequest {
// A long-lived resource, not a timer used to infer progress. Session
// close must cancel and reap it; no external service is contacted.
command: "printf cleanup-command; exec tail -f /dev/null".into(),
timeout_secs: 120,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
tool_call_id: None,
})
.await
.unwrap();
assert_eq!(
session.command_status(command.clone()).await.unwrap(),
workdir::CommandStatus::Running,
"fixture command must really be live rather than a failed shell launch"
);
command
}
async fn assert_live(session: &WorkdirSessionHandle) {
session
.stat(workdir::StatRequest {
path: workdir::WorkdirPath::new("README.md").unwrap(),
})
.await
.expect("new Runtime session must still serve the real materialized checkout");
}
async fn assert_closed(session: &WorkdirSessionHandle) {
session
.stat(workdir::StatRequest {
path: workdir::WorkdirPath::new("README.md").unwrap(),
})
.await
.expect_err("observed Runtime session must no longer serve filesystem operations");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn stop_ack_cleanup_preserves_real_restored_sessions_and_rebound_connection() {
crate::restore_test_environment::require_restore_test_environment();
let (completed, _completion) = tokio::sync::mpsc::channel(4);
let harness = ConnectedWorkerHarness::start(ConnectedAuthorizationModel { completed }).await;
let proxy = StopProxy::install(&harness).await;
let api = &harness.api;
let creation_probe_index = proxy.transport.failures.lock().unwrap().len();
let (status, created) = harness
.human(
reqwest::Method::POST,
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
json!({"runtime_id":CONNECTED_RUNTIME, "repository_key":"test-repository", "selector":"HEAD", "operation_id":"cleanup-connected-workdir"}),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{created}");
let workdir = created["item"]["working_directory_id"]
.as_str()
.unwrap()
.to_owned();
{
// create_workspace_working_directory probes its reserved Runtime ID
// before materializing it. Restrict this expectation to the sole failure
// during that completed creation request and to the returned exact ID.
// Later misses of the same Workdir must not inherit this exemption.
let failures = proxy.transport.failures.lock().unwrap();
assert_eq!(
failures.len(),
creation_probe_index + 1,
"creation must produce exactly one initial absence probe: {failures:?}"
);
assert!(
failures[creation_probe_index].is_expected_creation_probe(&workdir),
"unexpected creation probe response: {failures:?}"
);
}
let (status, created) = harness
.human(
reqwest::Method::POST,
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
json!({
"runtime_id":CONNECTED_RUNTIME, "display_name":"Connected stop cleanup",
"profile":"builtin:coder", "initial_submit":[],
"workdir_attachments":[{"alias":ALIAS,"working_directory_id":workdir}],
"feature_connections":{}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{created}");
let worker = RuntimeWorkerRef::new(
created["runtime_id"].as_str().unwrap(),
created["worker_id"].as_str().unwrap(),
);
let local_worker = worker_runtime::identity::WorkerRef::try_from(&worker).unwrap();
let _controller_cleanup = StopControllerOnDrop {
runtime: harness.runtime.clone(),
worker: local_worker.clone(),
};
assert!(
api.store
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &worker)
.unwrap()
.is_none(),
"this must remain the ordinary Worker stop/restore path"
);
let old_link = api
.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
.unwrap()
.into_iter()
.find(|link| link.unlinked_at.is_none() && link.alias == ALIAS)
.unwrap();
// Retain two independent pre-stop command sources. An attachment replacement
// normally closes the displaced session itself; do not manufacture an orphan
// by overwriting one and then demand that stop follow a removed registry row.
let (old_first_source, old_first_source_id) = proxy.open(api, &worker, &workdir).await;
let (old_command_source, old_command_id) = proxy.open(api, &worker, &workdir).await;
let old_first_provider_command = start_held_command(&old_first_source).await;
let old_provider_command = start_held_command(&old_command_source).await;
let (old_first_command, old_command) = {
let _guard = current_worker_session_lock(api, &worker).lock_owned().await;
let mut registry = api.workdir_sessions.lock().unwrap();
assert!(!registry.attachments.contains_key(&worker));
let first = registry.register_command(
worker.clone(),
ALIAS.into(),
old_first_source.clone(),
old_first_provider_command,
);
let second = registry.register_command(
worker.clone(),
ALIAS.into(),
old_command_source.clone(),
old_provider_command,
);
(first, second)
};
let stop_path = format!(
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/stop",
worker.runtime_id, worker.worker_id
);
let stop_api = api.clone();
let owner = harness.owner.clone();
let first_stop_path = stop_path.clone();
let stopping = tokio::spawn(async move {
response(
&stop_api,
human_request(
&owner,
"POST",
&first_stop_path,
Some(json!({"reason":"first real stop before restore"})),
),
)
.await
});
tokio::time::timeout(CLEANUP_TIMEOUT, proxy.transport.dispatched.notified())
.await
.expect("Backend stop must reach transport after its resource observation");
let session_guard = tokio::time::timeout(
CLEANUP_TIMEOUT,
current_worker_session_lock(api, &worker).lock_owned(),
)
.await
.expect("stop dispatch must not retain the pre-ACK session fence");
proxy.transport.release.add_permits(1);
tokio::time::timeout(CLEANUP_TIMEOUT, proxy.transport.acked.notified())
.await
.expect("real Runtime must acknowledge stop while Backend cleanup is fenced");
{
let acks = proxy.transport.acks.lock().unwrap();
assert_eq!(acks.len(), 1);
assert_eq!(acks[0].status, runtime_api::WorkerStatus::Stopped);
assert!(
!acks[0].replayed,
"must exercise fresh-ACK cleanup, not replay skip"
);
}
assert!(
!stopping.is_finished(),
"ACK must not bypass the session fence"
);
assert!(proxy.transport.closed_ids().is_empty());
// Provider sessions are not Controller state: actual Runtime stop alone has
// not closed the Backend-observed command sources.
assert_live(&old_first_source).await;
assert_live(&old_command_source).await;
let restore_api = api.clone();
let restore_worker = worker.clone();
let restored = tokio::time::timeout(
CLEANUP_TIMEOUT,
tokio::task::spawn_blocking(move || {
let stopped = restore_api.runtime.worker(&restore_worker).unwrap();
assert_eq!(stopped.state, "stopped");
restore_api
.runtime
.restore_worker(
&restore_worker,
runtime_api::WorkerRestoreRequest {
expected_observation_token: stopped.restore_observation_token.unwrap(),
request_id: "cleanup-connected-real-restore".into(),
preparation: Some(runtime_api::WorkerRestorePreparation::default()),
},
)
.unwrap()
}),
)
.await
.expect("real restore must not need the cleanup-owned Worker control lane")
.unwrap();
assert_eq!(
restored.state,
server_api::WorkerRestoreState::Accepted,
"{restored:?}"
);
assert_eq!(
harness.runtime.worker_detail(&local_worker).unwrap().status,
worker_runtime::catalog::WorkerStatus::Idle,
"the actual Runtime catalog must expose the reconstructed Controller"
);
// The fixture owns the production session fence throughout rebind and
// resource publication. Keep worker/alias/workdir/capabilities identical;
// only the storage-issued connection lifetime is different.
let detached = api
.store
.detach_worker_workdir_connection(
TEST_WORKSPACE_ID,
&worker,
ALIAS,
&old_link.connection_id,
&now_registry_timestamp(),
)
.unwrap();
assert_eq!(detached.connection_id, old_link.connection_id);
assert!(detached.unlinked_at.is_some());
let new_link = api.store.attach_worker_workdir(&old_link).unwrap();
assert_ne!(new_link.connection_id, old_link.connection_id);
assert_eq!(new_link.worker, old_link.worker);
assert_eq!(new_link.alias, old_link.alias);
assert_eq!(new_link.workdir_id, old_link.workdir_id);
assert_eq!(new_link.capabilities, old_link.capabilities);
let (new_attachment, new_attachment_id) = proxy.open(api, &worker, &workdir).await;
let (new_command_source, new_command_id) = proxy.open(api, &worker, &workdir).await;
let new_provider_command = start_held_command(&new_command_source).await;
let new_command = {
let mut registry = api.workdir_sessions.lock().unwrap();
assert!(
registry
.insert_attachment(worker.clone(), new_attachment.clone())
.is_none()
);
registry.register_command(
worker.clone(),
ALIAS.into(),
new_command_source.clone(),
new_provider_command.clone(),
)
};
let mut record = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
.unwrap()
.unwrap();
record.display_name = Some("new connection projection must survive old stop".into());
api.store.upsert_workdir_registry(&record).unwrap();
assert_live(&new_attachment).await;
assert_eq!(
new_command_source
.command_status(new_provider_command.clone())
.await
.unwrap(),
workdir::CommandStatus::Running,
);
assert!(!stopping.is_finished());
drop(session_guard);
let (status, stopped) = tokio::time::timeout(CLEANUP_TIMEOUT, stopping)
.await
.expect("old cleanup must finish after the session fence is released")
.unwrap();
assert_eq!(status, StatusCode::OK, "{stopped}");
assert_eq!(stopped["state"], "accepted", "{stopped}");
let mut old_ids = vec![old_first_source_id, old_command_id];
old_ids.sort();
assert_eq!(
proxy.transport.closed_ids(),
old_ids,
"close only pre-dispatch sessions"
);
assert_closed(&old_first_source).await;
assert_closed(&old_command_source).await;
assert_live(&new_attachment).await;
assert_live(&new_command_source).await;
assert_eq!(
new_command_source
.command_status(new_provider_command)
.await
.unwrap(),
workdir::CommandStatus::Running,
"old cleanup must not cancel a post-restore command"
);
{
let registry = api.workdir_sessions.lock().unwrap();
assert!(Arc::ptr_eq(
registry.attachments.get(&worker).unwrap(),
&new_attachment
));
assert!(
registry
.command(&worker, ALIAS, &old_first_command)
.is_none()
);
assert!(registry.command(&worker, ALIAS, &old_command).is_none());
assert!(Arc::ptr_eq(
&registry
.command(&worker, ALIAS, &new_command)
.unwrap()
.source,
&new_command_source,
));
}
assert_eq!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
.unwrap()
.unwrap()
.display_name,
record.display_name,
"old stop must not project a new connection even for identical worker/alias/workdir"
);
assert_eq!(
api.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
.unwrap()
.into_iter()
.find(|link| link.unlinked_at.is_none() && link.alias == ALIAS)
.unwrap()
.connection_id,
new_link.connection_id,
);
// Surviving resources are not leaked: a later explicit, non-replayed stop
// observes their own lifetimes and closes their actual Runtime sessions.
let (status, stopped) = harness
.human(
reqwest::Method::POST,
&stop_path,
json!({"reason":"explicit stop of restored Worker"}),
)
.await;
assert_eq!(status, StatusCode::OK, "{stopped}");
assert_eq!(stopped["state"], "accepted", "{stopped}");
{
let acks = proxy.transport.acks.lock().unwrap();
assert_eq!(acks.len(), 2);
assert_eq!(acks[1].status, runtime_api::WorkerStatus::Stopped);
assert!(!acks[1].replayed);
}
old_ids.extend([new_attachment_id, new_command_id]);
old_ids.sort();
assert_eq!(
proxy.transport.closed_ids(),
old_ids,
"each actual session closes once"
);
assert_closed(&new_attachment).await;
assert_closed(&new_command_source).await;
{
let registry = api.workdir_sessions.lock().unwrap();
assert!(!registry.attachments.contains_key(&worker));
assert!(registry.command(&worker, ALIAS, &new_command).is_none());
}
assert_ne!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
.unwrap()
.unwrap()
.display_name,
record.display_name,
"later stop must still project its current connection normally"
);
let runtime_errors = harness.runtime_errors.lock().unwrap().clone();
let failures = proxy.transport.failures.lock().unwrap();
let unexpected: Vec<_> = failures
.iter()
.enumerate()
.filter(|(index, failure)| {
*index != creation_probe_index && !failure.is_expected_closed_session()
})
.map(|(_, failure)| failure)
.collect();
assert!(
unexpected.is_empty(),
"unexpected Runtime HTTP errors: {unexpected:?}; runtime_errors={runtime_errors:?}; all HTTP failures={failures:?}"
);
// Do not silently lose failures from any Runtime request that bypassed our
// proxy. Reconcile even the shared harness's lossy flat-envelope messages.
// Apart from the single creation probe validated at its request boundary,
// a NotFound is expected only for operations on a session whose DELETE was
// already confirmed; a 401/403 or any other NotFound remains a failure.
let mut captured = runtime_errors.clone();
let mut classified: Vec<_> = failures
.iter()
.map(|failure| failure.harness_message.clone())
.collect();
captured.sort();
classified.sort();
assert_eq!(
captured, classified,
"unclassified Runtime errors: runtime_errors={runtime_errors:?}; HTTP failures={failures:?}"
);
}
@@ -0,0 +1,706 @@
//! Connected Subject Body API regressions in the normal Server lib target
//! (`subject_body_connected` filter). The authenticated human router, signed
//! Backend <-> RuntimeHTTP requests, ProfileRuntimeWorkerFactory/Controller,
//! singleton reservations, SQLite and canonical FS Session storage are real.
//! Only the external model stream is scripted. One transport fault discards an
//! already-produced stop response; it does not replace stop execution or receipts.
//! Existing Memory is fixture input through the domain store; Journal admission
//! uses real, authorized canonical Session capture, not invented source identity.
//!
//! Restore requires the runner-owned isolation checked by
//! `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::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const DEADLINE: Duration = Duration::from_secs(30);
#[derive(Clone)]
struct BodyModel(tokio::sync::mpsc::Sender<String>);
#[async_trait::async_trait]
impl agen::llm_client::LlmClient for BodyModel {
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: agen::llm_client::Request,
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
{
use agen::llm_client::event::{Event, StopReason};
for name in [
"ConversationRead",
"ConversationSend",
"SubjektivMemoryQuery",
] {
assert!(
request.tools.iter().any(|tool| tool.name == name),
"the unchanged Subject conversation profile must expose {name}"
);
}
self.0.send(format!("{:?}", request.items)).await.unwrap();
Ok(Box::pin(futures::stream::iter([
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "Retained internal body turn")),
Ok(Event::text_block_stop(0, Some(StopReason::EndTurn))),
])))
}
}
struct ConnectedBody {
connected: ConnectedWorkerHarness,
lose_stop_ack: Arc<AtomicBool>,
inputs: tokio::sync::mpsc::Receiver<String>,
}
impl ConnectedBody {
async fn new() -> Self {
// Fail before starting either server or touching restore allocation paths.
crate::restore_test_environment::require_restore_test_environment();
let (tx, inputs) = tokio::sync::mpsc::channel(8);
let lose_stop_ack = Arc::new(AtomicBool::new(false));
let fault = lose_stop_ack.clone();
let connected = ConnectedWorkerHarness::start_with_layer(BodyModel(tx), move |router| {
router.layer(axum::middleware::from_fn(
move |request: axum::extract::Request, next: axum::middleware::Next| {
let fault = fault.clone();
async move {
let discard = request.uri().path().ends_with("/body/stop")
&& fault.swap(false, Ordering::SeqCst);
let result = next.run(request).await;
if discard {
assert!(
result.status().is_success(),
"stop must execute before ACK loss"
);
return Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"message":"injected lost stop response"}).to_string(),
))
.unwrap();
}
result
}
},
))
})
.await;
// Body start intentionally chooses a Runtime itself and prefers embedded.
// Remove only the unused deterministic fixture Runtime via its real idle
// removal contract; never route around signed HTTP or replace execution.
assert!(matches!(
connected
.api
.runtime
.unregister_if_idle(EMBEDDED_WORKER_RUNTIME_ID, 100)
.unwrap(),
crate::hosts::RuntimeRegistryUnregisterResult::Removed
));
let available = connected.api.runtime.list_runtimes(100);
assert_eq!(available.items.len(), 1);
assert_eq!(available.items[0].runtime_id, CONNECTED_RUNTIME);
assert!(available.items[0].worker_creation_available);
Self {
connected,
lose_stop_ack,
inputs,
}
}
async fn request(&self, method: &str, path: &str, body: Value) -> (StatusCode, Value) {
self.connected
.human(method.parse().unwrap(), path, body)
.await
}
async fn ok(&self, method: &str, path: &str, body: Value) -> Value {
let (status, result) = self.request(method, path, body).await;
assert_eq!(status, StatusCode::OK, "{method} {path}: {result}");
result
}
async fn subject(&self, operation: &str) -> String {
let (status, created) = self
.request(
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
json!({"operation_id":operation,"role":"connected-body","behavior_md":""}),
)
.await;
assert_eq!(
status,
StatusCode::CREATED,
"normal Subject creation: {created}"
);
created["id"].as_str().unwrap().to_owned()
}
async fn read(&self, subject: &str) -> Value {
self.ok("GET", &body_path(subject), Value::Null).await
}
async fn mutate(&self, subject: &str, suffix: &str, request: Value) -> Value {
self.ok("POST", &format!("{}{suffix}", body_path(subject)), request)
.await
}
async fn stop(&self, subject: &str, request_id: &str) -> Value {
let current = self.read(subject).await;
let request = intent(request_id, &current);
let stopped = self.mutate(subject, "/stop", request.clone()).await;
assert_eq!(stopped["disposition"], "accepted", "{stopped}");
let outcome = self.mutate(subject, "/stop/outcome", request.clone()).await;
assert_eq!(outcome["outcome"], "stopped", "{outcome}");
request
}
fn worker_count(&self) -> usize {
self.connected
.api
.runtime
.list_workers_for_runtime(CONNECTED_RUNTIME, 100)
.unwrap()
.items
.len()
}
}
fn body_path(subject: &str) -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/body")
}
fn absent_start(request_id: &str) -> Value {
json!({"request_id":request_id,"expected_current":null,"expected_observation_token":null})
}
fn intent(request_id: &str, body: &Value) -> Value {
let token = body["observation"]["worker"]["restore_observation_token"]
.as_str()
.unwrap_or_else(|| {
panic!("available body must expose the Runtime observation fence: {body}")
});
json!({"request_id":request_id,"expected_current":body["current"],"expected_observation_token":token})
}
async fn body_idle(
events: &mut tokio::sync::broadcast::Receiver<
worker_runtime::observation::WorkerObservationEvent,
>,
worker: &str,
) {
tokio::time::timeout(DEADLINE, async {
loop {
let event = events.recv().await.expect("real Runtime observation");
if event.worker_ref.worker_id.to_string() != worker {
continue;
}
match event.payload {
protocol::Event::Error { code, message } => {
panic!("body turn failed: {code:?}: {message}")
}
protocol::Event::WorkerState { snapshot }
if snapshot.state == protocol::WorkerState::Idle =>
{
break;
}
_ => {}
}
}
})
.await
.expect("Controller must publish Idle after the scripted model turn");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn body_same_request_concurrent_start_stop_outcome_and_restore_preserve_subject_data() {
let mut f = ConnectedBody::new().await;
let subject = f.subject("body-new-subject").await;
let store = open_subjektiv_store(&f.connected.api).unwrap();
let initial = store.subject_permissions(&subject).unwrap().unwrap();
assert_eq!(
initial.execution,
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &subject)
);
assert!(initial.delegation.is_empty());
assert!(f.read(&subject).await["current"].is_null());
// Retention inputs are deliberately independent of Worker execution and of
// Ticket conclusion. Direct Memory setup is not a claimed body mutation.
let memory = store
.create_memory(
&subject,
crate::subjektiv::MemoryDraft::active(
memory::extract::CandidateKind::Decision,
"Keep this decision",
"Retained Memory body",
"Survive body stop/restore",
"retention fixture",
),
)
.unwrap();
let tickets = browser_ticket_backend(&f.connected.api).unwrap();
let ticket = tickets
.create(NewTicket::new("Body stop does not conclude this case"))
.unwrap();
let before_case = tickets.show(ticket.id.clone().into()).unwrap();
let conversations = format!("/api/w/{TEST_WORKSPACE_ID}/chat/conversations");
let conversation = f
.ok(
"POST",
&conversations,
json!({"operation_id":"body-conversation","subject_id":subject}),
)
.await;
let messages = format!(
"{}/{}/messages",
conversations,
conversation["id"].as_str().unwrap()
);
let saved = f
.ok(
"POST",
&messages,
json!({"operation_id":"body-before-start","text":"BEFORE_BODY_START_RETAINED"}),
)
.await;
assert_ne!(saved["delivery"]["state"], "accepted");
let start = absent_start("body-concurrent-same");
let path = format!("{}/start", body_path(&subject));
let (left, right) = tokio::join!(
f.request("POST", &path, start.clone()),
f.request("POST", &path, start.clone())
);
for (status, result) in [&left, &right] {
assert_eq!(*status, StatusCode::OK, "exact concurrent retry: {result}");
assert!(
matches!(result["disposition"].as_str(), Some("accepted" | "unknown")),
"{result}"
);
}
// An in-flight response is not assumed to settle the other call. Reconcile
// with the same request identity, never with a second creation request.
let replay = f.mutate(&subject, "/start", start.clone()).await;
assert_eq!(replay["disposition"], "accepted", "{replay}");
let running = f.read(&subject).await;
assert_eq!(running["current"]["runtime_id"], CONNECTED_RUNTIME);
assert_eq!(running["observation"]["kind"], "available");
assert_eq!(
running["observation"]["worker"]["profile"],
server_api::authorization::SUBJECT_CONVERSATION_PROFILE
);
assert_eq!(f.worker_count(), 1);
let current = running["current"].clone();
let worker = current["worker_id"].as_str().unwrap().to_owned();
let source = RuntimeWorkerRef::new(CONNECTED_RUNTIME, &worker);
let singleton = f
.connected
.api
.store
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source)
.unwrap()
.expect("normal body singleton owner");
assert_eq!(
serde_json::to_value(subject_body::reference(&singleton)).unwrap(),
current
);
let live_reuse = f
.mutate(&subject, "/start", intent("body-live-reuse", &running))
.await;
assert_eq!(live_reuse["disposition"], "accepted", "{live_reuse}");
assert_eq!(f.worker_count(), 1);
let mut events = f.connected.runtime.subscribe_worker_observation().unwrap();
let delivered = f
.ok(
"POST",
&messages,
json!({"operation_id":"body-live-message","text":"BODY_CANONICAL_SESSION_MARKER"}),
)
.await;
assert_eq!(delivered["delivery"]["state"], "accepted");
let input = tokio::time::timeout(DEADLINE, f.inputs.recv())
.await
.unwrap()
.unwrap();
assert!(input.contains("BODY_CANONICAL_SESSION_MARKER"), "{input}");
body_idle(&mut events, &worker).await;
let root = f.connected.runtime_root.join("workers").join(&worker);
let history = session_store::read_retained_session_snapshot(
&root,
&format!("worker-runtime-{worker}"),
2 * 1024 * 1024,
)
.unwrap();
assert!(
serde_json::to_string(&history.snapshot)
.unwrap()
.contains("BODY_CANONICAL_SESSION_MARKER")
);
let attribution = store
.subject_session_attributions(&subject)
.unwrap()
.into_iter()
.find(|entry| entry.worker_id == worker)
.expect("signed startup registered real Session");
assert_eq!(attribution.session_id, history.identity.session_id);
let host =
subjektiv::journal::JournalHostContext::validated_session(&store, attribution.clone())
.unwrap();
let capture =
subjektiv_journal::capture_input(&f.connected.api, &attribution, 0, 0, 128 * 1024).unwrap();
let ranges = subjektiv::journal::recording::capture_ranges(&capture).unwrap();
assert!(!ranges.is_empty(), "admit actual canonical Session bytes");
let journal = store
.accept_journal_sources(&host, "Retained body Journal", &ranges)
.unwrap();
let reader =
subjektiv::journal::JournalHostContext::validated_reader(&store, &subject).unwrap();
let before_journal =
serde_json::to_value(store.read_journal(&reader, &journal).unwrap()).unwrap();
let before_sources = serde_json::to_value(
store
.list_journal_sources(
&reader,
&journal,
None,
subjektiv::journal::MAX_JOURNAL_PAGE,
)
.unwrap(),
)
.unwrap();
assert!(
before_sources["next"].is_null(),
"the small retention fixture must compare its complete Journal source ledger: {before_sources}"
);
assert_eq!(
before_sources["items"].as_array().unwrap().len(),
ranges.len(),
"every admitted canonical source must be included in the retention comparison"
);
let before_messages = f.ok("GET", &messages, Value::Null).await;
let observed = f.read(&subject).await;
let stop = intent("body-stop-lost-ack", &observed);
f.lose_stop_ack.store(true, Ordering::SeqCst);
let (status, lost) = f
.request(
"POST",
&format!("{}/stop", body_path(&subject)),
stop.clone(),
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY, "{lost}");
// The caller has an unknown result. A GET is only current observation: it
// cannot prove which request stopped it, nor manufacture a receipt.
let stopped = f.read(&subject).await;
assert_eq!(stopped["current"], current);
assert_eq!(stopped["observation"]["worker"]["state"], "stopped");
let mut unrelated = stop.clone();
unrelated["request_id"] = json!("body-stop-never-sent");
let missing = f.mutate(&subject, "/stop/outcome", unrelated).await;
assert_eq!(
missing["outcome"], "not_found",
"GET must not invent stop acceptance: {missing}"
);
let outcome = f.mutate(&subject, "/stop/outcome", stop.clone()).await;
assert_eq!(
outcome["outcome"], "stopped",
"only exact outcome resolves the lost ACK: {outcome}"
);
let restore = intent("body-restore-same-generation", &stopped);
let restored = f.mutate(&subject, "/start", restore.clone()).await;
assert_eq!(
restored["disposition"],
"accepted",
"{restored}; Runtime errors: {:?}",
f.connected.runtime_errors.lock().unwrap()
);
assert_eq!(restored["body"]["current"], current);
assert_ne!(
restored["body"]["observation"]["worker"]["state"],
"stopped"
);
assert_eq!(
f.worker_count(),
1,
"restore must not create a replacement Worker/generation"
);
let replay_restore = f.mutate(&subject, "/start", restore).await;
assert_eq!(
replay_restore["disposition"], "accepted",
"{replay_restore}"
);
let historical = f.mutate(&subject, "/stop/outcome", stop.clone()).await;
assert_eq!(historical["outcome"], "stopped");
assert_ne!(
historical["body"]["observation"]["worker"]["state"], "stopped",
"historical stop receipt is not the restored body's current state"
);
let stop_replay = f.mutate(&subject, "/stop", stop).await;
assert_eq!(stop_replay["disposition"], "accepted");
assert_ne!(
f.read(&subject).await["observation"]["worker"]["state"],
"stopped",
"exact historical stop replay must not stop the restored lifetime"
);
for suffix in ["/start", "/stop"] {
let stale = intent("body-old-observation", &observed);
let (status, result) = f
.request("POST", &format!("{}{suffix}", body_path(&subject)), stale)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"old Runtime observation: {result}"
);
}
// Reopen authority rather than treating an earlier model callback as durable evidence.
let reopened = open_subjektiv_store(&f.connected.api).unwrap();
assert_eq!(
reopened.subject_permissions(&subject).unwrap(),
Some(initial)
);
assert_eq!(
serde_json::to_value(reopened.memory(&subject, &memory.id).unwrap().unwrap()).unwrap(),
serde_json::to_value(&memory).unwrap()
);
assert_eq!(
serde_json::to_value(reopened.read_journal(&reader, &journal).unwrap()).unwrap(),
before_journal
);
assert_eq!(
serde_json::to_value(
reopened
.list_journal_sources(
&reader,
&journal,
None,
subjektiv::journal::MAX_JOURNAL_PAGE
)
.unwrap()
)
.unwrap(),
before_sources
);
assert_eq!(f.ok("GET", &messages, Value::Null).await, before_messages);
let after_case = tickets.show(ticket.id.into()).unwrap();
assert_eq!(
after_case, before_case,
"body lifecycle must not conclude or edit the case"
);
assert_eq!(
reopened.subject(&subject).unwrap().unwrap().state,
crate::subjektiv::SubjectState::Active
);
let after_history = session_store::read_retained_session_snapshot(
&root,
&format!("worker-runtime-{worker}"),
2 * 1024 * 1024,
)
.unwrap();
assert_eq!(
after_history.identity.session_id,
history.identity.session_id
);
assert!(
serde_json::to_string(&after_history.snapshot)
.unwrap()
.contains("BODY_CANONICAL_SESSION_MARKER")
);
f.stop(&subject, "body-final-stop").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targets() {
let f = ConnectedBody::new().await;
let subject = f.subject("body-distinct-subject").await;
let other = f.subject("body-foreign-subject").await;
let path = format!("{}/start", body_path(&subject));
let (a, b) = tokio::join!(
f.request("POST", &path, absent_start("body-click-a")),
f.request("POST", &path, absent_start("body-click-b"))
);
assert!(
[&a, &b]
.iter()
.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!(f.worker_count(), 1);
let current = f.read(&subject).await;
let foreign = intent("body-foreign-intent", &current);
for suffix in ["/start", "/stop", "/stop/outcome"] {
let (status, result) = f
.request(
"POST",
&format!("{}{suffix}", body_path(&other)),
foreign.clone(),
)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"cross-Subject {suffix}: {result}"
);
}
let mut old_generation = foreign.clone();
old_generation["expected_current"]["generation"] = json!(
(current["current"]["generation"]
.as_str()
.unwrap()
.parse::<u64>()
.unwrap()
+ 1)
.to_string()
);
for suffix in ["/start", "/stop", "/stop/outcome"] {
let (status, result) = f
.request(
"POST",
&format!("{}{suffix}", body_path(&subject)),
old_generation.clone(),
)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"stale generation {suffix}: {result}"
);
}
let reader = seed_test_api_token(f.connected.api.store.as_ref(), "connected-body-reader");
for (method, suffix, body) in [
("GET", "", None),
("POST", "/start", Some(foreign.clone())),
("POST", "/stop", Some(foreign.clone())),
("POST", "/stop/outcome", Some(foreign.clone())),
] {
let (status, result) = response(
&f.connected.api,
human_request(
&reader,
method,
&format!("{}{suffix}", body_path(&subject)),
body,
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"non-owner {suffix}: {result}"
);
}
let wrong_workspace = body_path(&subject).replace(TEST_WORKSPACE_ID, "not-this-workspace");
let (status, result) = f
.request("POST", &format!("{wrong_workspace}/stop"), foreign)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "{result}");
assert_eq!(f.read(&subject).await["current"], current["current"]);
assert!(f.read(&other).await["current"].is_null());
assert_eq!(f.worker_count(), 1);
f.stop(&subject, "body-distinct-cleanup").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn body_start_and_restore_do_not_repair_missing_session_record_permission() {
let f = ConnectedBody::new().await;
let subject = f.subject("body-revoked-subject").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions");
let initial = open_subjektiv_store(&f.connected.api)
.unwrap()
.subject_permissions(&subject)
.unwrap()
.unwrap();
let execution = initial
.execution
.iter()
.filter(|permission| permission.operation != "session.record")
.cloned()
.collect::<Vec<_>>();
let revoked = f.ok("POST", &path, json!({"operation_id":"body-remove-session-record",
"expected_change_id":initial.change_id,"execution":execution,"delegation":initial.delegation})).await;
let (status, denied) = f
.request(
"POST",
&format!("{}/start", body_path(&subject)),
absent_start("body-denied-start"),
)
.await;
assert!(
status == StatusCode::FORBIDDEN
|| (status == StatusCode::OK && denied["disposition"] != "accepted"),
"required startup authorization must refuse: {status}: {denied}"
);
assert!(
denied.to_string().contains("session.record"),
"actionable missing-operation diagnostic: {denied}"
);
assert_eq!(
f.ok("GET", &path, Value::Null).await["permissions"],
revoked["permissions"]
);
assert!(
open_subjektiv_store(&f.connected.api)
.unwrap()
.subject_session_attributions(&subject)
.unwrap()
.is_empty(),
"refused startup must not register a protected Session"
);
// A separate Subject proves the same denial at restore, with normal defaults
// at creation and explicit owner revocation only after a successful start.
let subject = f.subject("body-restore-revoked-subject").await;
let started = f
.mutate(&subject, "/start", absent_start("body-authorized-start"))
.await;
assert_eq!(started["disposition"], "accepted", "{started}");
f.stop(&subject, "body-before-revoked-restore").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions");
let policy = open_subjektiv_store(&f.connected.api)
.unwrap()
.subject_permissions(&subject)
.unwrap()
.unwrap();
let execution = policy
.execution
.iter()
.filter(|permission| permission.operation != "session.record")
.cloned()
.collect::<Vec<_>>();
let revoked = f.ok("POST", &path, json!({"operation_id":"body-revoke-before-restore",
"expected_change_id":policy.change_id,"execution":execution,"delegation":policy.delegation})).await;
let observed = f.read(&subject).await;
let request = intent("body-denied-restore", &observed);
let denied = f.mutate(&subject, "/start", request.clone()).await;
assert_ne!(denied["disposition"], "accepted", "{denied}");
let replay = f.mutate(&subject, "/start", request).await;
assert_ne!(replay["disposition"], "accepted", "{replay}");
assert_eq!(
f.ok("GET", &path, Value::Null).await["permissions"],
revoked["permissions"]
);
assert_eq!(
f.read(&subject).await["current"],
observed["current"],
"denial must not replace singleton generation"
);
assert_eq!(
f.read(&subject).await["observation"]["worker"]["state"],
"stopped"
);
}
@@ -131,6 +131,10 @@ async fn body_operations_reject_nonowners_and_cross_workspace_targets() {
async fn body_stop_and_outcome_reject_a_target_from_another_subject_before_runtime_dispatch() { async fn body_stop_and_outcome_reject_a_target_from_another_subject_before_runtime_dispatch() {
let f = BodyFixture::new().await; let f = BodyFixture::new().await;
let lease = f.reserve(); let lease = f.reserve();
let permissions = open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.unwrap();
let other = open_subjektiv_store(&f.api) let other = open_subjektiv_store(&f.api)
.unwrap() .unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("other-companion").unwrap()) .create_subject(crate::subjektiv::SubjectRole::new("other-companion").unwrap())
@@ -157,12 +161,12 @@ async fn body_stop_and_outcome_reject_a_target_from_another_subject_before_runti
f.read().await["current"]["worker_id"], f.read().await["current"]["worker_id"],
lease.worker.worker_id lease.worker.worker_id
); );
assert!( assert_eq!(
open_subjektiv_store(&f.api) open_subjektiv_store(&f.api)
.unwrap() .unwrap()
.subject_permissions(&f.subject) .subject_permissions(&f.subject)
.unwrap() .unwrap(),
.is_none() permissions
); );
} }
@@ -235,13 +239,13 @@ async fn body_operations_reject_a_subject_with_deletion_already_admitted() {
"{status}: {body}" "{status}: {body}"
); );
} }
assert!( let deletion = open_subjektiv_store(&f.api)
open_subjektiv_store(&f.api) .unwrap()
.unwrap() .subject_deletion(&f.subject)
.subject(&f.subject) .unwrap()
.unwrap() .unwrap();
.is_some() assert_eq!(deletion.operation_id, "delete-body-fixture");
); assert!(!deletion.completed);
assert_eq!( assert_eq!(
f.api f.api
.store .store
@@ -277,6 +277,29 @@ impl Fixture {
RuntimeWorkerRef::new(&self.runtime_id, "registry-close-owner") RuntimeWorkerRef::new(&self.runtime_id, "registry-close-owner")
} }
async fn create_worker(&self) -> RuntimeWorkerRef {
self.source.publish_profile("builtin:companion");
let Json(created) = create_workspace_worker(
State(self.api().clone()),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: self.runtime_id.clone(),
display_name: "session-close Worker".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
.unwrap();
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
}
async fn open(&self) -> WorkdirSessionHandle { async fn open(&self) -> WorkdirSessionHandle {
self.api() self.api()
.runtime .runtime
@@ -734,7 +757,7 @@ async fn stop_projection_does_not_follow_a_rebound_connection_with_identical_wor
{ {
let fixture = Fixture::new().await; let fixture = Fixture::new().await;
let api = fixture.api(); let api = fixture.api();
let worker = fixture.registry_owner(); let worker = fixture.create_worker().await;
let link = WorkerWorkdirLinkRecord { let link = WorkerWorkdirLinkRecord {
connection_id: String::new(), connection_id: String::new(),
workspace_id: TEST_WORKSPACE_ID.into(), workspace_id: TEST_WORKSPACE_ID.into(),
@@ -763,7 +786,7 @@ async fn stop_projection_does_not_follow_a_rebound_connection_with_identical_wor
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.workdir_id) .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.workdir_id)
.unwrap() .unwrap()
.unwrap(); .unwrap();
record.display_name = "new binding not owned by old stop".into(); record.display_name = Some("new binding not owned by old stop".into());
api.store.upsert_workdir_registry(&record).unwrap(); api.store.upsert_workdir_registry(&record).unwrap();
sync_linked_workdir_after_worker_stop(api, &worker, &[old]).unwrap(); sync_linked_workdir_after_worker_stop(api, &worker, &[old]).unwrap();
assert_eq!( assert_eq!(
@@ -790,26 +813,7 @@ async fn stop_projection_does_not_follow_a_rebound_connection_with_identical_wor
async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runtime_lease() { async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runtime_lease() {
let fixture = Fixture::new().await; let fixture = Fixture::new().await;
let api = fixture.api(); let api = fixture.api();
fixture.source.publish_profile("builtin:companion"); let worker = fixture.create_worker().await;
let Json(created) = create_workspace_worker(
State(api.clone()),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: fixture.runtime_id.clone(),
display_name: "session-close removal".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachments: Vec::new(),
feature_connections: Default::default(),
control_operation_id: None,
execution_grant: None,
}),
)
.await
.unwrap();
let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
api.runtime api.runtime
.stop_worker( .stop_worker(
&worker, &worker,
+771 -2
View File
@@ -13414,6 +13414,16 @@
"null" "null"
] ]
}, },
"stop_intent": {
"anyOf": [
{
"$ref": "#/components/schemas/WorkerStopIntent"
},
{
"type": "null"
}
]
},
"ticket_assignment": { "ticket_assignment": {
"anyOf": [ "anyOf": [
{ {
@@ -13437,6 +13447,11 @@
}, },
"type": "array" "type": "array"
}, },
"replayed": {
"default": false,
"description": "Historical receipt replay, not a new stop or evidence of current state.",
"type": "boolean"
},
"runtime_id": { "runtime_id": {
"type": "string" "type": "string"
}, },
@@ -14784,6 +14799,259 @@
], ],
"type": "object" "type": "object"
}, },
"SubjectBodyDisposition": {
"enum": [
"accepted",
"rejected",
"unknown"
],
"type": "string"
},
"SubjectBodyMutationResponse": {
"additionalProperties": false,
"properties": {
"body": {
"anyOf": [
{
"$ref": "#/components/schemas/SubjectBodyResponse"
},
{
"type": "null"
}
],
"description": "Fresh observation is independent of the historical operation receipt."
},
"diagnostics": {
"items": {
"$ref": "#/components/schemas/Diagnostic"
},
"type": "array"
},
"disposition": {
"$ref": "#/components/schemas/SubjectBodyDisposition"
},
"request_id": {
"type": "string"
}
},
"required": [
"request_id",
"disposition",
"diagnostics"
],
"type": "object"
},
"SubjectBodyObservation": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "absent",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"kind": {
"const": "available",
"type": "string"
},
"worker": {
"$ref": "#/components/schemas/WorkerSummary"
}
},
"required": [
"kind",
"worker"
],
"type": "object"
},
{
"additionalProperties": false,
"description": "The owner is still occupied; this must not invite a replacement launch.",
"properties": {
"diagnostics": {
"items": {
"$ref": "#/components/schemas/Diagnostic"
},
"type": "array"
},
"kind": {
"const": "unavailable",
"type": "string"
}
},
"required": [
"kind",
"diagnostics"
],
"type": "object"
}
]
},
"SubjectBodyRef": {
"additionalProperties": false,
"properties": {
"generation": {
"description": "Decimal text preserves the full singleton generation through browsers.",
"type": "string"
},
"runtime_id": {
"type": "string"
},
"worker_id": {
"type": "string"
}
},
"required": [
"runtime_id",
"worker_id",
"generation"
],
"type": "object"
},
"SubjectBodyResponse": {
"additionalProperties": false,
"properties": {
"can_start": {
"description": "Account authority, not a claim that Profile/model/Runtime admission will succeed.",
"type": "boolean"
},
"can_stop": {
"type": "boolean"
},
"current": {
"anyOf": [
{
"$ref": "#/components/schemas/SubjectBodyRef"
},
{
"type": "null"
}
]
},
"observation": {
"$ref": "#/components/schemas/SubjectBodyObservation"
},
"subject_id": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
},
"required": [
"workspace_id",
"subject_id",
"observation",
"can_start",
"can_stop"
],
"type": "object"
},
"SubjectBodyStartRequest": {
"additionalProperties": false,
"properties": {
"expected_current": {
"additionalProperties": false,
"description": "Null explicitly means the caller observed no owner, not an unspecified target.",
"properties": {
"generation": {
"description": "Decimal text preserves the full singleton generation through browsers.",
"type": "string"
},
"runtime_id": {
"type": "string"
},
"worker_id": {
"type": "string"
}
},
"required": [
"runtime_id",
"worker_id",
"generation"
],
"type": "object"
},
"expected_observation_token": {
"type": [
"string",
"null"
]
},
"request_id": {
"type": "string"
}
},
"required": [
"request_id",
"expected_current"
],
"type": "object"
},
"SubjectBodyStopOutcome": {
"enum": [
"not_found",
"pending",
"stopped"
],
"type": "string"
},
"SubjectBodyStopOutcomeResponse": {
"additionalProperties": false,
"properties": {
"body": {
"anyOf": [
{
"$ref": "#/components/schemas/SubjectBodyResponse"
},
{
"type": "null"
}
]
},
"outcome": {
"$ref": "#/components/schemas/SubjectBodyStopOutcome",
"description": "Historical operation evidence, never the current body's running state."
},
"request_id": {
"type": "string"
}
},
"required": [
"request_id",
"outcome"
],
"type": "object"
},
"SubjectBodyStopRequest": {
"additionalProperties": false,
"properties": {
"expected_current": {
"$ref": "#/components/schemas/SubjectBodyRef"
},
"expected_observation_token": {
"type": "string"
},
"request_id": {
"type": "string"
}
},
"required": [
"request_id",
"expected_current",
"expected_observation_token"
],
"type": "object"
},
"SubjectPermissionInterruption": { "SubjectPermissionInterruption": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -15561,6 +15829,17 @@
"created_at": { "created_at": {
"type": "string" "type": "string"
}, },
"current_body": {
"anyOf": [
{
"$ref": "#/components/schemas/SubjectBodyRef"
},
{
"type": "null"
}
],
"description": "Current owner remains present even when its Runtime cannot be observed."
},
"current_worker": { "current_worker": {
"anyOf": [ "anyOf": [
{ {
@@ -15570,7 +15849,13 @@
"type": "null" "type": "null"
} }
], ],
"description": "Current keyed-singleton owner, when one exists. This is a live Yoi\nprojection, not state duplicated in the subjektiv store." "description": "Available launch metadata, not evidence of absence when observation failed."
},
"current_worker_diagnostics": {
"items": {
"$ref": "#/components/schemas/Diagnostic"
},
"type": "array"
}, },
"id": { "id": {
"type": "string" "type": "string"
@@ -20361,6 +20646,22 @@
], ],
"type": "object" "type": "object"
}, },
"WorkerStopIntent": {
"additionalProperties": false,
"properties": {
"expected_observation_token": {
"type": "string"
},
"request_id": {
"type": "string"
}
},
"required": [
"expected_observation_token",
"request_id"
],
"type": "object"
},
"WorkerSummary": { "WorkerSummary": {
"description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.", "description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.",
"properties": { "properties": {
@@ -22725,7 +23026,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c" "x-yoi-source-digest": "sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -41910,6 +42211,474 @@
] ]
} }
}, },
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body": {
"get": {
"operationId": "subjektiv_subject_body_get",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/start": {
"post": {
"operationId": "subjektiv_subject_body_start",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyStartRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyMutationResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop": {
"post": {
"operationId": "subjektiv_subject_body_stop",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyStopRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyMutationResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop/outcome": {
"post": {
"operationId": "subjektiv_subject_body_stop_outcome",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyStopRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjectBodyStopOutcomeResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
"get": { "get": {
"operationId": "subjektiv_memory_list", "operationId": "subjektiv_memory_list",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -76,7 +76,7 @@ export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id:
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, }; export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_body?: import("./subject-body-api").SubjectBodyRef | null, current_worker_diagnostics?: Array<Diagnostic>, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, }; export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -127,9 +127,9 @@ export type RuntimeWorkerInputRequest = { content: string; kind?: string | null;
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; }; export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
export type RuntimeWorkerLifecycleRequest = { reason?: string | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; }; export type RuntimeWorkerLifecycleRequest = { reason?: string | null; stop_intent?: WorkerStopIntent | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; };
export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; }; export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; replayed?: boolean; runtime_id: string; state: WorkerOperationState; worker_id: string; };
export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; }; export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
@@ -191,6 +191,8 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; }; export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
export type WorkerStopIntent = { expected_observation_token: string; request_id: string; };
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; }; export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; }; export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
@@ -0,0 +1,68 @@
// 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";
export type SubjectBodyRef = {
runtime_id: string;
worker_id: string;
/**
* Decimal text preserves the full singleton generation through browsers.
*/
generation: string;
};
export type SubjectBodyObservation = { "kind": "absent" } | {
"kind": "available";
worker: WorkerSummary;
} | { "kind": "unavailable"; diagnostics: Array<Diagnostic> };
export type SubjectBodyResponse = {
workspace_id: string;
subject_id: string;
current: SubjectBodyRef | null;
observation: SubjectBodyObservation;
/**
* Account authority, not a claim that Profile/model/Runtime admission will succeed.
*/
can_start: boolean;
can_stop: boolean;
};
export type SubjectBodyStartRequest = {
request_id: string;
/**
* Null explicitly means the caller observed no owner, not an unspecified target.
*/
expected_current: SubjectBodyRef | null;
expected_observation_token: string | null;
};
export type SubjectBodyStopRequest = {
request_id: string;
expected_current: SubjectBodyRef;
expected_observation_token: string;
};
export type SubjectBodyDisposition = "accepted" | "rejected" | "unknown";
export type SubjectBodyMutationResponse = {
request_id: string;
disposition: SubjectBodyDisposition;
diagnostics: Array<Diagnostic>;
/**
* Fresh observation is independent of the historical operation receipt.
*/
body: SubjectBodyResponse | null;
};
export type SubjectBodyStopOutcome = "not_found" | "pending" | "stopped";
export type SubjectBodyStopOutcomeResponse = {
request_id: string;
/**
* Historical operation evidence, never the current body's running state.
*/
outcome: SubjectBodyStopOutcome;
body: SubjectBodyResponse | null;
};
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c) // Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -319,7 +319,7 @@ export function parseRuntimeWorkerLifecycleResult(
const item = object(value, "Runtime Worker lifecycle result"); const item = object(value, "Runtime Worker lifecycle result");
exactKeys( exactKeys(
item, item,
["state", "runtime_id", "worker_id", "diagnostics", "replayed"], ["state", "runtime_id", "worker_id", "diagnostics"],
["replayed"], ["replayed"],
"Runtime Worker lifecycle result", "Runtime Worker lifecycle result",
); );
@@ -6,7 +6,7 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { tick } from "svelte"; import { tick } from "svelte";
import SubjectBody from "./SubjectBody.svelte"; import SubjectBody from "./SubjectBody.svelte";
import { import {
@@ -73,6 +73,23 @@ function savedStop(accountId = props.accountId) {
}; };
} }
// Happy DOM does not update bodyUsed when the production bounded reader consumes a stream.
// Observe releaseLock instead, so late-response assertions run after actual consumption.
function afterBodyRead(response: Response): Promise<void> {
return new Promise((resolve) => {
const getReader = response.body!.getReader.bind(response.body);
vi.spyOn(response.body!, "getReader").mockImplementation(() => {
const reader = getReader();
const release = reader.releaseLock.bind(reader);
vi.spyOn(reader, "releaseLock").mockImplementation(() => {
release();
resolve();
});
return reader;
});
});
}
function deferred<T>() { function deferred<T>() {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => { const promise = new Promise<T>((accept) => {
@@ -80,6 +97,17 @@ function deferred<T>() {
}); });
return { promise, resolve }; return { promise, resolve };
} }
// Happy DOM Storage is a Proxy with bound own methods; spy on a plain adapter
// rather than its unrelated Storage.prototype or non-restorable proxy properties.
beforeEach(() => {
const storage = sessionStorage;
vi.stubGlobal("sessionStorage", {
getItem: storage.getItem.bind(storage),
setItem: storage.setItem.bind(storage),
removeItem: storage.removeItem.bind(storage),
clear: storage.clear.bind(storage),
});
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -337,7 +365,7 @@ test("storage write failure sends nothing and exposes a recovery diagnostic", as
const start = await screen.findByRole("button", { const start = await screen.findByRole("button", {
name: "Start conversation body", name: "Start conversation body",
}); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { vi.spyOn(sessionStorage, "setItem").mockImplementation(() => {
throw new Error("storage denied"); throw new Error("storage denied");
}); });
await fireEvent.click(start); await fireEvent.click(start);
@@ -366,8 +394,9 @@ test("late GET for an old route cannot overwrite the selected Subject", async ()
await view.rerender({ ...props, subjectId: "other" }); await view.rerender({ ...props, subjectId: "other" });
await screen.findByText(/You do not have permission to start or stop/); await screen.findByText(/You do not have permission to start or stop/);
const response = Response.json(available()); const response = Response.json(available());
const consumed = afterBodyRead(response);
old.resolve(response); old.resolve(response);
await waitFor(() => expect(response.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
await waitFor(() => await waitFor(() =>
expect(screen.getByText("No current conversation body.")) expect(screen.getByText("No current conversation body."))
@@ -414,8 +443,9 @@ test("late old-route mutation receipt cannot update new route or discard old rec
diagnostics: [], diagnostics: [],
body: available(), body: available(),
}); });
const consumed = afterBodyRead(response);
pending.resolve(response); pending.resolve(response);
await waitFor(() => expect(response.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
await waitFor(() => await waitFor(() =>
expect(screen.getByText("No current conversation body.")) expect(screen.getByText("No current conversation body."))
@@ -753,8 +783,9 @@ test("late stop outcome cannot settle an old route request after navigation", as
outcome: "stopped", outcome: "stopped",
body: available(), body: available(),
}); });
const consumed = afterBodyRead(response);
pending.resolve(response); pending.resolve(response);
await waitFor(() => expect(response.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
expect(screen.queryByText("Historical stop confirmed.")).toBeNull(); expect(screen.queryByText("Historical stop confirmed.")).toBeNull();
expect(screen.queryByRole("button", { name: "Check stop outcome" })) expect(screen.queryByRole("button", { name: "Check stop outcome" }))
@@ -805,7 +836,7 @@ test("account switch hides old body and retry input while same-account reload pr
const view = render(SubjectBody, props); const view = render(SubjectBody, props);
await screen.findByText("Body stopped."); await screen.findByText("Body stopped.");
await screen.findByText(/An unresolved stop request is retained/); await screen.findByText(/An unresolved stop request is retained/);
const reads = vi.spyOn(Storage.prototype, "getItem"); const reads = vi.spyOn(sessionStorage, "getItem");
reads.mockClear(); reads.mockClear();
account = accountB.accountId; account = accountB.accountId;
await view.rerender(accountB); await view.rerender(accountB);
@@ -875,8 +906,9 @@ test("late GET from another account cannot replace the same route's new observat
await view.rerender({ ...props, accountId: account }); await view.rerender({ ...props, accountId: account });
await screen.findByText(/You do not have permission to start or stop/); await screen.findByText(/You do not have permission to start or stop/);
const oldResponse = Response.json(available()); const oldResponse = Response.json(available());
const consumed = afterBodyRead(oldResponse);
pendingA.resolve(oldResponse); pendingA.resolve(oldResponse);
await waitFor(() => expect(oldResponse.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
expect(screen.getByText("No current conversation body.")).toBeTruthy(); expect(screen.getByText("No current conversation body.")).toBeTruthy();
expect(screen.queryByText("Body stopped.")).toBeNull(); expect(screen.queryByText("Body stopped.")).toBeNull();
@@ -962,7 +994,7 @@ test.each([false, true])(
).toBe(false) ).toBe(false)
); );
} }
const removals = vi.spyOn(Storage.prototype, "removeItem"); const removals = vi.spyOn(sessionStorage, "removeItem");
const getsBeforeResponse = gets; const getsBeforeResponse = gets;
const oldResponse = Response.json({ const oldResponse = Response.json({
request_id: requestA, request_id: requestA,
@@ -970,8 +1002,9 @@ test.each([false, true])(
diagnostics: [], diagnostics: [],
body: available(), body: available(),
}); });
const consumed = afterBodyRead(oldResponse);
pendingA.resolve(oldResponse); pendingA.resolve(oldResponse);
await waitFor(() => expect(oldResponse.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
expect(screen.queryByText("Request accepted.")).toBeNull(); expect(screen.queryByText("Request accepted.")).toBeNull();
expect(screen.queryByText("Body stopped.")).toBeNull(); expect(screen.queryByText("Body stopped.")).toBeNull();
@@ -1024,14 +1057,15 @@ test("late stop outcome from another account cannot settle or clear either accou
expect( expect(
screen.queryByText("Checking saved stop outcome… No stop request is sent."), screen.queryByText("Checking saved stop outcome… No stop request is sent."),
).toBeNull(); ).toBeNull();
const removals = vi.spyOn(Storage.prototype, "removeItem"); const removals = vi.spyOn(sessionStorage, "removeItem");
const oldResponse = Response.json({ const oldResponse = Response.json({
request_id: first.request.request_id, request_id: first.request.request_id,
outcome: "stopped", outcome: "stopped",
body: available(), body: available(),
}); });
const consumed = afterBodyRead(oldResponse);
pending.resolve(oldResponse); pending.resolve(oldResponse);
await waitFor(() => expect(oldResponse.bodyUsed).toBe(true)); await consumed;
await tick(); await tick();
expect(screen.queryByText("Historical stop confirmed.")).toBeNull(); expect(screen.queryByText("Historical stop confirmed.")).toBeNull();
expect(screen.queryByText("Body stopped.")).toBeNull(); expect(screen.queryByText("Body stopped.")).toBeNull();
@@ -1057,7 +1091,7 @@ test("signout hides body and request details and never falls back to a dummy ide
const view = render(SubjectBody, props); const view = render(SubjectBody, props);
await screen.findByText("Body stopped."); await screen.findByText("Body stopped.");
expect(screen.getByText(first.request.request_id)).toBeTruthy(); expect(screen.getByText(first.request.request_id)).toBeTruthy();
const reads = vi.spyOn(Storage.prototype, "getItem"); const reads = vi.spyOn(sessionStorage, "getItem");
reads.mockClear(); reads.mockClear();
await view.rerender({ ...props, accountId: "" }); await view.rerender({ ...props, accountId: "" });
await screen.findByText("Sign in to view this conversation body."); await screen.findByText("Sign in to view this conversation body.");
@@ -1071,3 +1105,227 @@ test("signout hides body and request details and never falls back to a dummy ide
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
expect(sessionStorage.getItem(key)).toBe(raw); expect(sessionStorage.getItem(key)).toBe(raw);
}); });
test("Workspace switch fences late mutation even when the Subject ID is identical", async () => {
const pending = deferred<Response>();
let requestId = "";
const other = { ...props, workspaceId: "other-workspace" };
vi.stubGlobal(
"fetch",
vi.fn(async (path: string, init?: RequestInit) => {
if (init?.method === "POST") {
requestId = JSON.parse(init.body as string).request_id;
return pending.promise;
}
return Response.json(
path.includes("/other-workspace/")
? {
...absent(),
workspace_id: other.workspaceId,
can_start: false,
can_stop: false,
}
: absent(),
);
}),
);
const view = render(SubjectBody, props);
await fireEvent.click(
await screen.findByRole("button", { name: "Start conversation body" }),
);
const key = subjectBodyStorageKey(
props.accountId,
props.workspaceId,
props.subjectId,
);
const retained = sessionStorage.getItem(key);
await view.rerender(other);
await screen.findByText(/You do not have permission to start or stop/);
const response = Response.json({
request_id: requestId,
disposition: "accepted",
diagnostics: [],
body: available(),
});
const consumed = afterBodyRead(response);
pending.resolve(response);
await consumed;
await tick();
expect(screen.queryByText("Request accepted.")).toBeNull();
expect(screen.queryByText("Body stopped.")).toBeNull();
expect(sessionStorage.getItem(key)).toBe(retained);
expect(
readSubjectBodyAttempt(
sessionStorage,
props.accountId,
other.workspaceId,
props.subjectId,
),
).toBeNull();
await view.rerender(props);
await screen.findByText(/An unresolved start request is retained/);
});
test("double click sends only one retained request, never a conversation ACK", async () => {
const pending = deferred<Response>();
const posts: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_path: string, init?: RequestInit) => {
if (init?.method !== "POST") return Response.json(absent());
posts.push(init.body as string);
return pending.promise;
}),
);
render(SubjectBody, props);
const start = await screen.findByRole("button", {
name: "Start conversation body",
});
await fireEvent.click(start);
await fireEvent.click(start);
expect(posts).toHaveLength(1);
// A chat-submit acknowledgement is not a body mutation receipt.
const response = Response.json({
command_id: "chat-command",
state: "accepted",
});
const consumed = afterBodyRead(response);
pending.resolve(response);
await consumed;
await screen.findByText(/Request outcome unknown\. The request may/);
expect(
readSubjectBodyAttempt(
sessionStorage,
props.accountId,
props.workspaceId,
props.subjectId,
)?.request,
)
.toEqual(JSON.parse(posts[0]));
expect(screen.queryByText("Request accepted.")).toBeNull();
});
test("old generation rejection stays distinct from fresh body and requires a fresh explicit stop", async () => {
let observation = available("idle");
const posts: {
expected_current: typeof owner;
expected_observation_token: string;
}[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_path: string, init?: RequestInit) => {
if (init?.method !== "POST") return Response.json(observation);
const request = JSON.parse(init.body as string);
posts.push(request);
observation = available("idle", "generation-18-token");
observation.current = { ...owner, generation: "18" };
return Response.json({
request_id: request.request_id,
disposition: "rejected",
diagnostics: [{
code: "stale_owner",
severity: "warning",
message:
"The current body generation changed. Refresh before acting.",
}],
body: observation,
});
}),
);
render(SubjectBody, props);
await fireEvent.click(
await screen.findByRole("button", { name: "Stop conversation body" }),
);
await screen.findByText("Request rejected.");
await screen.findByText(
"The current body generation changed. Refresh before acting.",
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Stop conversation body",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(posts).toHaveLength(1);
expect(posts[0].expected_current.generation).toBe("17");
expect(screen.queryByText("Body stopped.")).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Stop conversation body" }),
);
await waitFor(() => expect(posts).toHaveLength(2));
expect(posts[1].expected_current.generation).toBe("18");
expect(posts[1].expected_observation_token).toBe("generation-18-token");
});
test("unknown stop cannot restart on GET alone; confirmed outcome permits restart with fresh token", async () => {
let observation = available("idle");
let outcome = "pending";
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);
if (path.endsWith("/outcome")) {
return Response.json({
request_id: request.request_id,
outcome,
body: null,
});
}
return Response.json({
request_id: request.request_id,
disposition: "unknown",
diagnostics: [],
body: null,
});
}),
);
render(SubjectBody, props);
await fireEvent.click(
await screen.findByRole("button", { name: "Stop conversation body" }),
);
await screen.findByText("Request outcome unknown.");
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Refresh body state",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
observation = available("stopped", "after-confirmed-stop");
await fireEvent.click(
screen.getByRole("button", { name: "Refresh body state" }),
);
await screen.findByText("Body stopped.");
expect(screen.queryByRole("button", { name: "Restart conversation body" }))
.toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Check stop outcome" }),
);
await screen.findByText("Stop outcome pending.");
expect(screen.queryByRole("button", { name: "Restart conversation body" }))
.toBeNull();
outcome = "stopped";
await fireEvent.click(
screen.getByRole("button", { name: "Check stop outcome" }),
);
await screen.findByText("Historical stop confirmed.");
const restart = await screen.findByRole("button", {
name: "Restart conversation body",
});
await waitFor(() =>
expect((restart as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.click(restart);
await waitFor(() => expect(posts).toHaveLength(4));
expect(posts[1].body).toBe(posts[0].body);
expect(posts[2].body).toBe(posts[0].body);
const request = JSON.parse(posts[3].body);
expect(posts[3].path).toMatch(/\/start$/);
expect(request.expected_current).toEqual(owner);
expect(request.expected_observation_token).toBe("after-confirmed-stop");
expect(request.request_id).not.toBe(JSON.parse(posts[0].body).request_id);
});
@@ -110,7 +110,7 @@ test("renders the subject index as the Memory product entry", () => {
expect(screen.getByRole("link", { name: /Release coordinator/ })).not expect(screen.getByRole("link", { name: /Release coordinator/ })).not
.toBeNull(); .toBeNull();
expect(screen.getByText("subject-1")).not.toBeNull(); expect(screen.getByText("subject-1")).not.toBeNull();
expect(screen.getByText("Not connected")).not.toBeNull(); expect(screen.getByText("No current body")).not.toBeNull();
expect(screen.queryByText("Memory fingerprint", { exact: true })).toBeNull(); expect(screen.queryByText("Memory fingerprint", { exact: true })).toBeNull();
expect(screen.getByText("1 shown · more available")).not.toBeNull(); expect(screen.getByText("1 shown · more available")).not.toBeNull();
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href")) expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
@@ -346,8 +346,14 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.toContain("Prefer explicit evidence."); .toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident context" })).not expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull(); .toBeNull();
expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); // Legacy connection fields cannot authorize or infer the current singleton body.
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull(); expect(screen.queryByText("Connected", { exact: true })).toBeNull();
expect(screen.queryByText("Release Worker", { exact: true })).toBeNull();
expect(
screen.getByText(
"Confirming account identity before loading body controls…",
),
).toBeTruthy();
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull(); expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
expect(screen.getByText("Memory fingerprint", { exact: true })).not expect(screen.getByText("Memory fingerprint", { exact: true })).not
.toBeNull(); .toBeNull();
@@ -791,7 +797,12 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
expect(screen.getByText("None", { exact: true })).not.toBeNull(); expect(screen.getByText("None", { exact: true })).not.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); expect(
screen.getByText(
"Confirming account identity before loading body controls…",
{ exact: true },
),
).not.toBeNull();
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
expect(screen.queryByRole("article", { name: "Resident context" })) expect(screen.queryByRole("article", { name: "Resident context" }))
.toBeNull(); .toBeNull();
@@ -834,7 +845,12 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
expect(screen.getByText("Generated Memory context", { exact: true })).not expect(screen.getByText("Generated Memory context", { exact: true })).not
.toBeNull(); .toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); expect(
screen.getByText(
"Confirming account identity before loading body controls…",
{ exact: true },
),
).not.toBeNull();
const technical = view.container.querySelector( const technical = view.container.querySelector(
"details.subject-technical-details", "details.subject-technical-details",
) as HTMLDetailsElement; ) as HTMLDetailsElement;
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env -S deno run -A
/** Actual Web build + synthetic HTTP only. No real Backend/Runtime is contacted. */
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { chromium } from "playwright";
import { sha256File } from "../../../tools/web-ux/src/artifacts.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../tools/web-ux/src/processes.ts";
import {
assertNoPageWideOverflow,
freePort,
} from "../../../tools/web-ux/browser-tests/memory_document_harness.ts";
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);
await Deno.mkdir(output); // Never replace previous evidence.
const buildRoot = join(root, "web/workspace/build");
await Deno.stat(join(buildRoot, "index.html"));
const git = async (args: string[]) => {
const result = await new Deno.Command("git", {
args,
cwd: root,
stdout: "piped",
}).output();
assert(result.success);
return new TextDecoder().decode(result.stdout).trim();
};
const manifest = {
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend-or-Runtime",
source: {
commit: await git(["rev-parse", "HEAD"]),
status: await git(["status", "--short"]),
},
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
browser: "",
status: "running",
visualVerdict: "unreviewed",
expectedFaults: [
"structured session.record rejection",
"unknown start/stop receipts",
"stale generation rejection",
"503 body GET",
"navigation teardown aborts GET /workers refresh",
],
captures: [] as unknown[],
assertions: [] as string[],
failures: [] as string[],
};
const persist = () =>
Deno.writeTextFile(
join(output, "review-context.json"),
JSON.stringify(manifest, null, 2),
);
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const owned = await startOwnedProcesses(
[{
id: "synthetic-memory-http",
command: Deno.execPath(),
cwd: join(root, "tools/web-ux"),
args: [
"run",
"--cached-only",
"-A",
"browser-tests/memory_document_fixture_server.ts",
String(port),
buildRoot,
"3",
],
readyUrl: `${base}/health`,
}],
join(output, "scenario.json"),
join(output, "logs"),
[],
);
let browser;
try {
browser = await chromium.launch({ headless: true });
manifest.browser = browser.version();
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const prefix = `${colorScheme}-${width}`;
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
let accountId = "owner";
let observation: "absent" | "idle" | "stopped" | "unavailable" =
"absent";
let permission = true;
let getFailure = false;
let action: "reject" | "unknown" | "accepted" | "stale" = "reject";
let outcome: "pending" | "stopped" = "pending";
let generation = "17";
const posts: { path: string; body: string }[] = [];
const owner = () => ({
runtime_id: "runtime",
worker_id: "worker",
generation,
});
function body(workspace: string, subject: string) {
return {
workspace_id: workspace,
subject_id: subject,
can_start: permission,
can_stop: permission,
current: observation === "absent" ? null : owner(),
observation: observation === "absent"
? { kind: "absent" }
: observation === "unavailable"
? {
kind: "unavailable",
diagnostics: [{
code: "runtime_unavailable",
severity: "warning",
message:
"Runtime observation unavailable; no replacement body will be started.",
}],
}
: {
kind: "available",
worker: {
runtime_id: "runtime",
worker_id: "worker",
resource_key: "W-1",
host_id: "host",
label: "Conversation body",
workspace: {
visibility: "workspace",
identity: workspace,
workspace_id: workspace,
},
implementation: { kind: "embedded", display_hint: "" },
availability: "observed",
state: observation,
worker_state: observation === "stopped"
? null
: { last_command_id: 1, state: { kind: "idle" } },
restore_observation_token:
`token-${generation}-${observation}`,
},
},
};
}
await context.route("**/api/auth/whoami", async (route) => {
const response = await route.fetch();
const value = await response.json();
value.actor.account_id = accountId;
await route.fulfill({ response, json: value });
});
// The second Workspace uses a synthetic, silent protocol channel too.
await context.routeWebSocket(
"**/api/w/other-review/protocol/ws",
(socket) => {
socket.onMessage(() => {});
},
);
// Reuse the existing isolated fixture for a second Workspace identity.
await context.route(
/\/api\/w\/other-review(?:\/.*)?$/,
async (route) => {
const response = await route.fetch({
url: route.request().url().replace(
"/api/w/other-review/",
"/api/w/memory-review/",
),
});
await route.fulfill({
response,
body: (await response.text()).replaceAll(
"memory-review",
"other-review",
),
});
},
);
await context.route(
/\/api\/w\/[^/]+\/subjektiv\/subjects\/[^/]+\/body(?:\/.*)?$/,
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const [, workspace, subject] = path.match(
/\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/body/,
)!;
if (request.method() === "GET") {
await route.fulfill({
status: getFailure ? 503 : 200,
json: getFailure
? { error: "Synthetic body observation unavailable" }
: body(workspace, subject),
});
return;
}
const raw = request.postData()!;
posts.push({ path, body: raw });
const input = JSON.parse(raw);
assert(!("accountId" in input));
if (path.endsWith("/outcome")) {
await route.fulfill({
json: { request_id: input.request_id, outcome, body: null },
});
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,
diagnostics: action === "reject"
? [{
code: "permission_missing",
severity: "error",
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),
},
});
},
);
const page = await context.newPage();
const errors: unknown[] = [];
let documentStatus: number | null = null;
page.on(
"pageerror",
(e) => errors.push({ kind: "page", message: e.message }),
);
page.on("console", (e) => {
if (["warning", "error"].includes(e.type())) {
errors.push({ kind: "console", message: e.text() });
}
});
page.on(
"requestfailed",
(r) =>
errors.push({
kind: "request",
url: r.url(),
message: r.failure()?.errorText,
}),
);
page.on("response", (r) => {
if (r.request().isNavigationRequest()) documentStatus = r.status();
if (r.status() >= 400) {
errors.push({ kind: "http", status: r.status(), url: r.url() });
}
});
const panel = () =>
page.getByRole("region", { name: "Conversation body", exact: true });
const button = (name: string) =>
panel().getByRole("button", { name, exact: true });
async function ready() {
await page.locator(".subject-body header button:not(:disabled)")
.waitFor();
}
async function refresh() {
await ready();
await button("Refresh body state").click();
await ready();
}
async function capture(name: string, expectedErrors: string[] = []) {
await panel().scrollIntoViewIfNeeded();
const id = `${prefix}-${name}`;
const image = join(output, `${id}.png`);
const aria = join(output, `${id}.aria.txt`);
const panelImage = join(output, `${id}.panel.png`);
await page.screenshot({ path: image });
await panel().screenshot({ path: panelImage });
await Deno.writeTextFile(
aria,
await page.locator("body").ariaSnapshot(),
);
manifest.captures.push({
id,
route: new URL(page.url()).pathname,
accountId,
viewport: page.viewportSize(),
theme: colorScheme,
documentStatus,
visibleAlerts: await page.getByRole("alert").allTextContents(),
bodyText: await panel().innerText(),
errors: [...errors],
expectedErrors,
image,
imageSha256: await sha256File(image),
panelImage,
panelImageSha256: await sha256File(panelImage),
accessibilitySnapshot: aria,
reviewed: false,
});
await persist();
await assertNoPageWideOverflow(page);
}
// Enter through the ordinary Subjects list, not a special-purpose shell.
await page.goto(`${base}/w/memory-review/memory`);
await page.locator('a.subject-row[href$="/empty-subject"]').click();
await panel().getByText("No current conversation body.", {
exact: true,
}).waitFor();
await ready();
await button("Refresh body state").focus();
await page.keyboard.press("Tab");
assert(
await button("Start conversation body").evaluate((el) =>
el === el.ownerDocument.activeElement
),
);
await capture("before-absent-focus");
await page.keyboard.press("Enter");
await panel().getByText("Request rejected.", { exact: true }).waitFor();
await ready();
await capture("permission-rejection");
action = "unknown";
await button("Start conversation body").click();
await panel().getByText("Request outcome unknown.", { exact: true })
.waitFor();
await ready();
const unknownInput = posts.at(-1)!.body;
observation = "idle";
await refresh();
assertEquals(await button("Start conversation body").count(), 0);
await capture("unknown-after-get");
await page.reload();
await panel().getByText(/An unresolved start request is retained/)
.waitFor();
await ready();
action = "accepted";
await button("Retry same start request").click();
await panel().getByText("Request accepted.", { exact: true }).waitFor();
await ready();
assertEquals(posts.at(-1)!.body, unknownInput);
await capture("running-reconciled");
action = "unknown";
await button("Stop conversation body").click();
await panel().getByText("Request outcome unknown.", { exact: true })
.waitFor();
await ready();
const stopInput = posts.at(-1)!.body;
await button("Check stop outcome").click();
await panel().getByText("Stop outcome pending.", { exact: true })
.waitFor();
await ready();
observation = "stopped";
await refresh();
assertEquals(await button("Restart conversation body").count(), 0);
await capture("stopped-but-unresolved");
outcome = "stopped";
await button("Check stop outcome").click();
await panel().getByText("Historical stop confirmed.", { exact: true })
.waitFor();
await ready();
assertEquals(posts.at(-1)!.body, stopInput);
await capture("stop-confirmed");
action = "accepted";
await button("Restart conversation body").click();
await panel().getByText("Request accepted.", { exact: true }).waitFor();
await ready();
const restart = JSON.parse(posts.at(-1)!.body);
assertEquals(restart.expected_observation_token, "token-17-stopped");
assertEquals(restart.expected_current.generation, "17");
assert(restart.request_id !== JSON.parse(stopInput).request_id);
await capture("restarted-running");
action = "stale";
await button("Stop conversation body").click();
await panel().getByText(
"The current body generation changed. Refresh before acting.",
{ exact: true },
).waitFor();
await ready();
assertEquals(
JSON.parse(posts.at(-1)!.body).expected_current.generation,
"17",
);
await capture("old-generation-rejected");
permission = false;
await refresh();
assertEquals(await button("Stop conversation body").count(), 0);
await capture("read-only");
permission = true;
observation = "unavailable";
await refresh();
assertEquals(await button("Start conversation body").count(), 0);
await capture("unavailable-owner");
// Account identity changes must not expose another account's retained retry.
observation = "absent";
action = "unknown";
await refresh();
await button("Start conversation body").click();
await panel().getByText("Request outcome unknown.", { exact: true })
.waitFor();
await ready();
const beforeIdentitySwitch = posts.length;
accountId = "other-account";
await page.reload();
await panel().getByText("No current conversation body.", {
exact: true,
}).waitFor();
await ready();
assertEquals(await button("Retry same start request").count(), 0);
assertEquals(posts.length, beforeIdentitySwitch);
// Ordinary back-to-list and Subject navigation must isolate the old request too.
await page.getByRole("link", { name: "← All subjects", exact: true })
.click();
await page.locator('a.subject-row[href$="/stale-subject"]').click();
await panel().getByText("No current conversation body.", {
exact: true,
}).waitFor();
await ready();
assertEquals(await button("Retry same start request").count(), 0);
await capture("identity-subject-switched");
await button("Start conversation body").click();
await panel().getByText("Request outcome unknown.", { exact: true })
.waitFor();
await ready();
const beforeWorkspaceSwitch = posts.length;
await page.goto(`${base}/w/other-review/memory/stale-subject`);
await panel().getByText("No current conversation body.", {
exact: true,
}).waitFor();
await ready();
assertEquals(await button("Retry same start request").count(), 0);
assertEquals(posts.length, beforeWorkspaceSwitch);
await capture("workspace-switched");
await page.goto(`${base}/w/memory-review/memory/stale-subject`);
await panel().getByText(/An unresolved start request is retained/)
.waitFor();
await ready();
assertEquals(posts.length, beforeWorkspaceSwitch);
getFailure = true;
await refresh();
await panel().getByRole("alert").waitFor();
assert(await button("Retry same start request").isDisabled());
await capture("get-failure", [
"intentional 503 body GET; browser console reports failed resource",
]);
const unexpected = errors.filter((e: any) => {
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");
}
if (e.kind === "console") {
return !e.message.includes("503 (Service Unavailable)");
}
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`,
);
} finally {
await context.close();
}
}
}
manifest.status = "pass";
} catch (error) {
manifest.status = "fail";
manifest.failures.push(
error instanceof Error ? error.stack ?? error.message : String(error),
);
throw error;
} finally {
await persist();
await browser?.close();
await stopOwnedProcesses(owned);
}
console.log(output);
@@ -297,3 +297,25 @@ Deno.test("Restore parser bounds diagnostics, validates wrapper/summary identity
"wire contract", "wire contract",
); );
}); });
Deno.test("lifecycle replay metadata is optional but validated when present", () => {
const receipt = {
state: "accepted",
runtime_id: "runtime-a",
worker_id: "worker-1",
diagnostics: [],
};
assert(
parseRuntimeWorkerLifecycleResult(receipt).replayed === false,
"missing replay is not a replay",
);
assert(
parseRuntimeWorkerLifecycleResult({ ...receipt, replayed: true })
.replayed === true,
"replay flag lost",
);
assertThrows(
() => parseRuntimeWorkerLifecycleResult({ ...receipt, replayed: "true" }),
"must be a boolean",
);
});