feat: preserve Subject body lifecycle candidate for integration

Checkpoint all source-only T807 work, including Runtime stop fencing, Subject adapters, shared UI and unexecuted regression sources before merging published T806. Not a validated or reviewed release.
This commit is contained in:
2026-10-31 13:08:47 +09:00
parent 7252e61a23
commit 2f74e00e07
50 changed files with 6531 additions and 119 deletions
+96 -1
View File
@@ -623,9 +623,29 @@ pub struct WorkerDeleteResponse {
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerLifecycleRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_intent: Option<WorkerStopIntent>,
}
/// Fences a normal Worker stop to the observed lifecycle, not a Subject ledger.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerStopIntent {
pub expected_observation_token: String,
pub request_id: String,
}
/// Historical stop evidence. Stopped does not describe the current execution.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkerStopOutcome {
NotFound,
Pending,
Stopped { ack: WorkerLifecycleAck },
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
@@ -633,6 +653,10 @@ pub struct EmptyObjectRequest {}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkerLifecycleAck {
/// Historical typed stop receipt, not a fresh stop of the current execution.
/// Consumers must not repeat cleanup of current resources when this is true.
#[serde(default)]
pub replayed: bool,
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1410,13 +1434,29 @@ pub trait RuntimeApi {
#[body] input: WorkerInput,
) -> Result<WorkerInputResponse, RuntimeApiError>;
#[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400)]
#[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400, additional_error_statuses = [409])]
async fn stop_worker(
&self,
#[path] worker_id: String,
#[body] request: WorkerLifecycleRequest,
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
/// Admit the same fenced stop without backend cleanup; requires stop_intent.
#[post("/v1/workers/{worker_id}/stop/admit", status = 200, error_status = 400, additional_error_statuses = [409])]
async fn coordinate_worker_stop(
&self,
#[path] worker_id: String,
#[body] request: WorkerLifecycleRequest,
) -> Result<WorkerStopOutcome, RuntimeApiError>;
/// Read-only exact-identity lookup; never initiates backend stop cleanup.
#[post("/v1/workers/{worker_id}/stop/outcome", status = 200, error_status = 400, additional_error_statuses = [409])]
async fn worker_stop_outcome(
&self,
#[path] worker_id: String,
#[body] request: WorkerLifecycleRequest,
) -> Result<WorkerStopOutcome, RuntimeApiError>;
#[post("/v1/workers/{worker_id}/cancel", status = 200, error_status = 400)]
async fn cancel_worker(
&self,
@@ -1667,6 +1707,47 @@ mod tests {
assert_eq!(roundtrip, binding);
}
#[test]
fn lifecycle_ack_legacy_default_and_replay_marker_round_trip() {
let mut value = serde_json::json!({
"worker_ref": {"worker_id": WorkerId::now_v7()}, "status": "stopped"
});
let legacy: WorkerLifecycleAck = serde_json::from_value(value.clone()).unwrap();
assert!(!legacy.replayed);
value["replayed"] = serde_json::json!(false);
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
value["replayed"] = serde_json::json!(true);
let replay: WorkerLifecycleAck = serde_json::from_value(value.clone()).unwrap();
assert!(replay.replayed);
assert_eq!(serde_json::to_value(replay).unwrap(), value);
}
#[test]
fn lifecycle_stop_intent_defaults_and_strict_external_fields() {
for value in [
serde_json::json!({}),
serde_json::json!({"reason": "cancel"}),
] {
let request: WorkerLifecycleRequest = serde_json::from_value(value.clone()).unwrap();
assert!(request.stop_intent.is_none());
assert_eq!(serde_json::to_value(request).unwrap(), value);
}
let value = serde_json::json!({"reason":"stop", "stop_intent": {
"expected_observation_token":"observed", "request_id":"same-request"
}});
let request: WorkerLifecycleRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), value);
for invalid in [
serde_json::json!({"unknown":true}),
serde_json::json!({"stop_intent":{}}),
serde_json::json!({"stop_intent":{"request_id":"r"}}),
serde_json::json!({"stop_intent":{"expected_observation_token":"t"}}),
serde_json::json!({"stop_intent":{"expected_observation_token":"t", "request_id":"r", "unknown":true}}),
] {
assert!(serde_json::from_value::<WorkerLifecycleRequest>(invalid).is_err());
}
}
#[test]
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
for invalid in [
@@ -1974,6 +2055,20 @@ mod tests {
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn coordinate_worker_stop(
&self,
_worker_id: String,
_request: WorkerLifecycleRequest,
) -> Result<WorkerStopOutcome, RuntimeApiError> {
Err(test_error(501))
}
async fn worker_stop_outcome(
&self,
_worker_id: String,
_request: WorkerLifecycleRequest,
) -> Result<WorkerStopOutcome, RuntimeApiError> {
Err(test_error(501))
}
async fn cancel_worker(
&self,
_worker_id: String,
+4
View File
@@ -43,6 +43,10 @@ name = "generate_worker_launch_api_types"
[[example]]
name = "generate_companion_api_types"
[[example]]
name = "generate_subject_body_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_memory_api_types"
required-features = ["typescript"]
@@ -0,0 +1,6 @@
fn main() {
print!(
"{}",
server_api::normalized_typescript(server_api::subject_body_api_typescript())
);
}
+65 -3
View File
@@ -9,6 +9,8 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
pub mod authorization;
mod chat;
pub use chat::*;
mod subject_body;
pub use subject_body::*;
mod chat_transport;
pub use chat_transport::*;
mod drive;
@@ -250,6 +252,11 @@ impl_openapi_schema!(
PluginArtifactDeliveryRequest,
PluginArtifactDeliveryMetadata,
PluginWorkerDiagnostics,
SubjectBodyResponse,
SubjectBodyStartRequest,
SubjectBodyStopRequest,
SubjectBodyMutationResponse,
SubjectBodyStopOutcomeResponse,
SubjectPermissionsResponse,
SubjectPermissionsMutationResponse,
SubjectPermissionInterruption,
@@ -1540,6 +1547,45 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body", status = 200, error_status = 404,
additional_error_statuses = [400, 401, 403, 409, 500], bearer_auth = true, browser_auth = true)]
async fn subjektiv_subject_body_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjectBodyResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/start", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn subjektiv_subject_body_start(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjectBodyStartRequest,
) -> Result<SubjectBodyMutationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn subjektiv_subject_body_stop(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjectBodyStopRequest,
) -> Result<SubjectBodyMutationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop/outcome", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500, 502], bearer_auth = true, browser_auth = true)]
async fn subjektiv_subject_body_stop_outcome(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjectBodyStopRequest,
) -> Result<SubjectBodyStopOutcomeResponse, RepositoryApiError>;
#[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
status = 200,
@@ -5687,8 +5733,12 @@ pub struct SubjektivSubjectResponse {
pub memory_fingerprint: String,
pub created_at: String,
pub updated_at: String,
/// Current keyed-singleton owner, when one exists. This is a live Yoi
/// projection, not state duplicated in the subjektiv store.
/// Current owner remains present even when its Runtime cannot be observed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_body: Option<SubjectBodyRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub current_worker_diagnostics: Vec<Diagnostic>,
/// Available launch metadata, not evidence of absence when observation failed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_worker: Option<WorkerLaunchWorkerSummary>,
}
@@ -9676,9 +9726,18 @@ pub struct RuntimeWorkerInputResult {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkerStopIntent {
pub expected_observation_token: String,
pub request_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerLifecycleRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_intent: Option<WorkerStopIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -9688,6 +9747,9 @@ pub struct RuntimeWorkerLifecycleRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerLifecycleResult {
/// Historical receipt replay, not a new stop or evidence of current state.
#[serde(default)]
pub replayed: bool,
pub state: WorkerOperationState,
pub runtime_id: String,
pub worker_id: String,
@@ -11261,7 +11323,7 @@ export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id:
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, };
+195
View File
@@ -0,0 +1,195 @@
//! Subject-facing projections and intents over ordinary Worker lifecycle authority.
//! No current-body mapping or execution state is owned by these DTOs.
use crate::{Diagnostic, WorkerSummary};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyRef {
pub runtime_id: String,
pub worker_id: String,
/// Decimal text preserves the full singleton generation through browsers.
pub generation: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SubjectBodyObservation {
Absent,
Available {
worker: WorkerSummary,
},
/// The owner is still occupied; this must not invite a replacement launch.
Unavailable {
diagnostics: Vec<Diagnostic>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyResponse {
pub workspace_id: String,
pub subject_id: String,
pub current: Option<SubjectBodyRef>,
pub observation: SubjectBodyObservation,
/// Account authority, not a claim that Profile/model/Runtime admission will succeed.
pub can_start: bool,
pub can_stop: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyStartRequest {
pub request_id: String,
/// Null explicitly means the caller observed no owner, not an unspecified target.
#[serde(deserialize_with = "required_nullable_current")]
#[schemars(required)]
pub expected_current: Option<SubjectBodyRef>,
pub expected_observation_token: Option<String>,
}
fn required_nullable_current<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<SubjectBodyRef>, D::Error> {
Option::<SubjectBodyRef>::deserialize(deserializer)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyStopRequest {
pub request_id: String,
pub expected_current: SubjectBodyRef,
pub expected_observation_token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SubjectBodyDisposition {
Accepted,
Rejected,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyMutationResponse {
pub request_id: String,
pub disposition: SubjectBodyDisposition,
pub diagnostics: Vec<Diagnostic>,
/// Fresh observation is independent of the historical operation receipt.
pub body: Option<SubjectBodyResponse>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SubjectBodyStopOutcome {
NotFound,
Pending,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct SubjectBodyStopOutcomeResponse {
pub request_id: String,
/// Historical operation evidence, never the current body's running state.
pub outcome: SubjectBodyStopOutcome,
pub body: Option<SubjectBodyResponse>,
}
#[cfg(feature = "typescript")]
pub fn subject_body_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
SubjectBodyRef::decl(&config),
SubjectBodyObservation::decl(&config),
SubjectBodyResponse::decl(&config),
SubjectBodyStartRequest::decl(&config),
SubjectBodyStopRequest::decl(&config),
SubjectBodyDisposition::decl(&config),
SubjectBodyMutationResponse::decl(&config),
SubjectBodyStopOutcome::decl(&config),
SubjectBodyStopOutcomeResponse::decl(&config),
];
format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.\n// Do not edit manually.\n\nimport type {{ Diagnostic, WorkerSummary }} from './worker-launch-api';\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn start_requires_an_explicit_nullable_current_observation() {
assert!(
serde_json::from_value::<SubjectBodyStartRequest>(json!({"request_id":"start"}))
.is_err()
);
let request: SubjectBodyStartRequest = serde_json::from_value(json!({"request_id":"start", "expected_current":null, "expected_observation_token":null})).unwrap();
assert!(request.expected_current.is_none());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_subject_body_contract_is_current() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/subject-body-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
let normalize = |value: &str| {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect::<String>()
.replace("=|", "=")
.replace(";}", "}")
};
assert_eq!(
normalize(&actual),
normalize(&subject_body_api_typescript()),
"regenerate subject-body-api.ts using generate_subject_body_api_types and format the result"
);
}
#[test]
fn stop_intent_requires_target_and_observation() {
let valid = json!({
"request_id": "stop-1",
"expected_current": {"runtime_id":"runtime", "worker_id":"worker", "generation":"18446744073709551615"},
"expected_observation_token":"observed"
});
let request: SubjectBodyStopRequest = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(request.expected_current.generation, "18446744073709551615");
for field in [
"expected_current",
"expected_observation_token",
"request_id",
] {
let mut missing = valid.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<SubjectBodyStopRequest>(missing).is_err());
}
}
}
+41
View File
@@ -397,16 +397,57 @@ pub struct WorkerDetail {
/// Acknowledgement returned by stop/cancel lifecycle operations.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerLifecycleAck {
/// Historical typed stop receipt; never authorizes cleanup of current resources.
#[serde(default)]
pub replayed: bool,
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
}
impl WorkerLifecycleAck {
pub(crate) fn historical_stop_receipt(&self) -> Self {
Self {
replayed: true,
..self.clone()
}
}
}
/// Retained stop evidence, independent of the Worker's current execution.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkerStopOutcome {
NotFound,
Pending,
Stopped { ack: WorkerLifecycleAck },
}
#[cfg(test)]
mod tests {
use super::WorkspaceApiRef;
#[test]
fn legacy_stop_receipt_without_marker_is_marked_only_when_returned_as_history() {
let value = serde_json::json!({
"worker_ref": {"worker_id": crate::identity::WorkerId::now_v7()}, "status":"stopped"
});
let receipt: super::WorkerLifecycleAck = serde_json::from_value(value).unwrap();
assert!(!receipt.replayed);
let returned = receipt.historical_stop_receipt();
assert!(returned.replayed);
assert!(
!receipt.replayed,
"the retained receipt must not be rewritten"
);
let wire = serde_json::to_value(&returned).unwrap();
let external: runtime_api::WorkerLifecycleAck =
serde_json::from_value(wire.clone()).unwrap();
assert!(external.replayed);
assert_eq!(serde_json::to_value(external).unwrap(), wire);
}
#[test]
fn workspace_api_ref_public_schema_contains_no_source_credentials_or_claim_choices() {
let value = serde_json::to_value(WorkspaceApiRef {
+18
View File
@@ -15,6 +15,24 @@ pub enum RuntimeError {
#[error("worker {worker_id} Restore observation conflicts with the current operation")]
RestoreObservationConflict { worker_id: WorkerId },
#[error(
"worker {worker_id} lifecycle lock is busy; release caller gates and retry the same Restore intent"
)]
RestoreAdmissionBusy { worker_id: WorkerId },
#[error("worker {worker_id} Stop observation conflicts with the current lifecycle")]
StopObservationConflict { worker_id: WorkerId },
#[error(
"worker {worker_id} Stop request ID is already bound to different input or another pending stop"
)]
StopRequestConflict { worker_id: WorkerId },
#[error(
"worker {worker_id} lifecycle lock is busy; release caller gates and retry the same Stop intent"
)]
StopAdmissionBusy { worker_id: WorkerId },
#[error("worker {worker_id} was not found")]
WorkerNotFound { worker_id: WorkerId },
+79
View File
@@ -278,11 +278,27 @@ impl FsRuntimeStore {
atomic_write_json(&self.runtime_path(), &snapshot, "write runtime snapshot")
}
/// Use the canonical pretty-JSON representation, including its final newline,
/// to refuse writes that our bounded canonical reader could not reopen.
pub(crate) fn validate_worker_record_size(
&self,
worker: &PersistedWorkerRecord,
) -> Result<(), RuntimeError> {
let bytes = worker_record_serialized_size(worker)?;
if bytes > MAX_WORKER_RECORD_BYTES {
return Err(RuntimeError::InvalidRequest(format!(
"Worker aggregate requires {bytes} bytes, exceeding canonical reader limit {MAX_WORKER_RECORD_BYTES}; retained lifecycle owners cannot be evicted"
)));
}
Ok(())
}
pub(crate) fn write_worker_record(
&self,
worker: &PersistedWorkerRecord,
) -> Result<(), RuntimeError> {
self.ensure_worker_ref(&worker.worker_ref)?;
self.validate_worker_record_size(worker)?;
let worker_dir = self.worker_dir(&worker.worker_id);
fs::create_dir_all(&worker_dir).map_err(|source| RuntimeError::StoreIo {
operation: "create Worker store",
@@ -3033,6 +3049,33 @@ where
})
}
// Count rather than allocate a second aggregate-sized buffer during admission.
// This is the same serializer and trailing newline as atomic_write_json_with_fault.
fn worker_record_serialized_size(worker: &PersistedWorkerRecord) -> Result<u64, RuntimeError> {
struct ByteCount(u64);
impl Write for ByteCount {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0 = self
.0
.checked_add(bytes.len() as u64)
.ok_or_else(|| std::io::Error::other("Worker aggregate size overflow"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut count = ByteCount(0);
serde_json::to_writer_pretty(&mut count, &WorkerAggregateRecord::from_persisted(worker))
.map_err(|error| {
RuntimeError::InvalidRequest(format!("serialize Worker aggregate: {error}"))
})?;
count
.write_all(b"\n")
.map_err(|error| RuntimeError::InvalidRequest(error.to_string()))?;
Ok(count.0)
}
fn atomic_write_json<T>(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError>
where
T: Serialize,
@@ -4024,6 +4067,42 @@ mod tests {
assert!(error.to_string().contains("exceeds limit"));
}
#[test]
fn canonical_worker_writer_enforces_reader_byte_limit_including_final_newline() {
let (_temp, store, mut worker, path) = canonical_worker_store();
worker.execution_state = PersistedWorkerExecutionState::Unavailable;
worker.status = WorkerStatus::Stopped;
worker.display_name = Some(String::new());
let base = worker_record_serialized_size(&worker).unwrap();
worker.display_name = Some("x".repeat((MAX_WORKER_RECORD_BYTES - base) as usize));
store.write_worker_record(&worker).unwrap();
assert_eq!(fs::metadata(&path).unwrap().len(), MAX_WORKER_RECORD_BYTES);
assert_eq!(
worker_record_serialized_size(&worker).unwrap(),
MAX_WORKER_RECORD_BYTES
);
store.read_worker_record(&worker.worker_ref).unwrap();
let before = fs::read(&path).unwrap();
worker.display_name.as_mut().unwrap().push('x');
assert_eq!(
worker_record_serialized_size(&worker).unwrap(),
MAX_WORKER_RECORD_BYTES + 1
);
assert!(
matches!(store.write_worker_record(&worker), Err(RuntimeError::InvalidRequest(message)) if message.contains("canonical reader limit"))
);
assert_eq!(fs::read(&path).unwrap(), before);
assert_eq!(
store
.read_worker_record(&worker.worker_ref)
.unwrap()
.display_name
.unwrap()
.len(),
(MAX_WORKER_RECORD_BYTES - base) as usize
);
}
#[test]
fn worker_aggregate_write_distinguishes_pre_and_post_rename_failures() {
let temp = tempfile::tempdir().unwrap();
+122 -9
View File
@@ -523,11 +523,7 @@ pub type RuntimeHttpWorkerCompletionsRequest = runtime_api::CompletionRequest;
pub type RuntimeHttpWorkerCompletionsResponse = runtime_api::CompletionResponse;
/// Worker lifecycle request body used by stop/cancel endpoints.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkerLifecycleRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub type RuntimeHttpWorkerLifecycleRequest = runtime_api::WorkerLifecycleRequest;
/// Worker lifecycle acknowledgement response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -1902,8 +1898,8 @@ async fn stop_worker(
let ack = match auth_workspace_scope(&state, auth.as_ref())? {
Some(scope) => state
.runtime
.stop_worker_scoped(&scope, &worker_ref, request.reason),
None => state.runtime.stop_worker(&worker_ref, request.reason),
.stop_worker_request_scoped(&scope, &worker_ref, request),
None => state.runtime.stop_worker_request(&worker_ref, request),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
@@ -1918,6 +1914,7 @@ async fn cancel_worker(
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let request = parse_optional_lifecycle_request(body)?;
validate_cancel_request(&request)?;
let ack = match auth_workspace_scope(&state, auth.as_ref())? {
Some(scope) => state
.runtime
@@ -1942,6 +1939,19 @@ fn worker_ref_for(
Ok(WorkerRef::new(worker_id))
}
fn validate_cancel_request(
request: &runtime_api::WorkerLifecycleRequest,
) -> Result<(), RuntimeHttpRestError> {
if request.stop_intent.is_some() {
return Err(RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"invalid_request",
"stop_intent is only valid for stop, not cancel",
));
}
Ok(())
}
fn parse_optional_lifecycle_request(
body: Bytes,
) -> Result<RuntimeHttpWorkerLifecycleRequest, RuntimeHttpRestError> {
@@ -2160,6 +2170,8 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
return Some("workers:delete");
}
if path.ends_with("/stop")
|| path.ends_with("/stop/outcome")
|| path.ends_with("/stop/admit")
|| path.ends_with("/cancel")
|| (path == "/v1/subject-release" && *method == Method::POST)
{
@@ -2432,6 +2444,10 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
}
RuntimeError::RuntimeStopped
| RuntimeError::RestoreObservationConflict { .. }
| RuntimeError::RestoreAdmissionBusy { .. }
| RuntimeError::StopObservationConflict { .. }
| RuntimeError::StopRequestConflict { .. }
| RuntimeError::StopAdmissionBusy { .. }
| RuntimeError::RuntimeStoreAlreadyOpen { .. }
| RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. }
@@ -2459,6 +2475,10 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
RuntimeError::RestoreObservationConflict { .. } => {
"restore_observation_conflict".to_string()
}
RuntimeError::RestoreAdmissionBusy { .. } => "restore_admission_busy".to_string(),
RuntimeError::StopObservationConflict { .. } => "stop_observation_conflict".to_string(),
RuntimeError::StopRequestConflict { .. } => "stop_request_conflict".to_string(),
RuntimeError::StopAdmissionBusy { .. } => "stop_admission_busy".to_string(),
RuntimeError::RuntimeStoreAlreadyOpen { .. } => "runtime_store_already_open".to_string(),
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
RuntimeError::WorkerExecutionUnavailable { .. } => {
@@ -3728,13 +3748,80 @@ mod tests {
input.submission_request_id
);
}
client
.stop_worker(
let stop_request = runtime_api::WorkerLifecycleRequest {
reason: Some("generated fenced stop".into()),
stop_intent: Some(runtime_api::WorkerStopIntent {
expected_observation_token: client
.get_worker(generated_worker_id.clone())
.await
.unwrap()
.worker
.restore_observation_token
.unwrap(),
request_id: "generated-stop".into(),
}),
};
assert_eq!(
client
.worker_stop_outcome(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap(),
runtime_api::WorkerStopOutcome::NotFound
);
let invalid_cancel = client
.cancel_worker(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap_err();
assert!(
matches!(invalid_cancel, runtime_api::client_support::ClientError::Public { status, .. } if status == StatusCode::BAD_REQUEST)
);
assert_eq!(
client
.coordinate_worker_stop(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap(),
runtime_api::WorkerStopOutcome::Pending
);
assert_eq!(
client
.coordinate_worker_stop(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap(),
runtime_api::WorkerStopOutcome::Pending
);
assert_eq!(
client
.get_worker(generated_worker_id.clone())
.await
.unwrap()
.worker
.status,
runtime_api::WorkerStatus::Idle
);
let mut stop_receipt = client
.stop_worker(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap()
.ack;
let cancelled = client
.cancel_worker(
generated_worker_id.clone(),
runtime_api::WorkerLifecycleRequest::default(),
)
.await
.unwrap();
assert!(!cancelled.ack.replayed);
assert!(!stop_receipt.replayed);
stop_receipt.replayed = true; // Outcome and retry return historical evidence.
assert_eq!(
client
.worker_stop_outcome(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap(),
runtime_api::WorkerStopOutcome::Stopped {
ack: stop_receipt.clone()
}
);
client
.restore_worker(
generated_worker_id.clone(),
@@ -3752,6 +3839,32 @@ mod tests {
)
.await
.unwrap();
assert_eq!(
client
.stop_worker(generated_worker_id.clone(), stop_request.clone())
.await
.unwrap()
.ack,
stop_receipt
);
assert_eq!(
client
.get_worker(generated_worker_id.clone())
.await
.unwrap()
.worker
.status,
runtime_api::WorkerStatus::Idle
);
let mut changed_stop = stop_request.clone();
changed_stop.reason = Some("different request input".into());
let conflict = client
.stop_worker(generated_worker_id.clone(), changed_stop)
.await
.unwrap_err();
assert!(
matches!(conflict, runtime_api::client_support::ClientError::Public { status, .. } if status == StatusCode::CONFLICT)
);
let conflict = client.create_worker(conflicting_request).await.unwrap_err();
assert!(
matches!(
@@ -678,11 +678,12 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => self
.state
.runtime
.stop_worker_scoped(&scope, &worker_ref, value.reason),
None => self.state.runtime.stop_worker(&worker_ref, value.reason),
Some(scope) => {
self.state
.runtime
.stop_worker_request_scoped(&scope, &worker_ref, value)
}
None => self.state.runtime.stop_worker_request(&worker_ref, value),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
@@ -690,11 +691,57 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
Ok(runtime_api::WorkerLifecycleResponse { ack })
}
async fn coordinate_worker_stop(
&self,
worker_id: String,
value: runtime_api::WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let outcome = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => {
self.state
.runtime
.coordinate_worker_stop_scoped(&scope, &worker_ref, value)
}
None => self
.state
.runtime
.coordinate_worker_stop(&worker_ref, value),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
response(outcome)
}
async fn worker_stop_outcome(
&self,
worker_id: String,
value: runtime_api::WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let outcome = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => {
self.state
.runtime
.worker_stop_outcome_scoped(&scope, &worker_ref, value)
}
None => self.state.runtime.worker_stop_outcome(&worker_ref, value),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
response(outcome)
}
async fn cancel_worker(
&self,
worker_id: String,
value: runtime_api::WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
validate_cancel_request(&value).map_err(api_error)?;
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
File diff suppressed because it is too large Load Diff
@@ -275,6 +275,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerLifecycleRequest {
stop_intent: None,
reason,
ticket_assignment: None,
},
@@ -285,6 +286,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerLifecycleRequest {
stop_intent: None,
reason,
ticket_assignment: None,
},
+192 -2
View File
@@ -711,6 +711,8 @@ pub struct WorkerStopResult {
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerLifecycleRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_intent: Option<runtime_api::WorkerStopIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -719,6 +721,8 @@ pub struct WorkerLifecycleRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerLifecycleResult {
#[serde(default)]
pub replayed: bool,
pub state: InternalWorkerOperationState,
#[serde(flatten)]
pub worker: RuntimeWorkerRef,
@@ -878,6 +882,14 @@ impl RuntimeRegistryError {
} => {
if code == "restore_observation_conflict" {
Error::RestoreObservationConflict
} else if matches!(
code.as_str(),
"stop_observation_conflict"
| "stop_request_conflict"
| "stop_admission_busy"
| "restore_admission_busy"
) {
Error::RepositoryConflict(message)
} else {
Error::RuntimeOperationFailed {
runtime_id,
@@ -1188,12 +1200,37 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn coordinate_worker_stop(
&self,
_worker_id: &str,
_request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
Err(diagnostic(
"worker_stop_admission_unsupported",
HostDiagnosticSeverity::Error,
"Runtime does not support fenced stop admission".into(),
))
}
fn worker_stop_outcome(
&self,
_worker_id: &str,
_request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
Err(diagnostic(
"worker_stop_outcome_unsupported",
HostDiagnosticSeverity::Error,
"Runtime does not support stop outcome queries".into(),
))
}
fn stop_worker(
&self,
worker_id: &str,
_request: WorkerLifecycleRequest,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Unsupported,
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
diagnostics: vec![diagnostic(
@@ -1212,6 +1249,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
_request: WorkerLifecycleRequest,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Unsupported,
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
diagnostics: vec![diagnostic(
@@ -2119,6 +2157,40 @@ impl RuntimeRegistry {
Ok(runtime.worker_completions(worker_id, request))
}
pub fn coordinate_worker_stop(
&self,
worker: &RuntimeWorkerRef,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_id)?;
let runtime = self.runtime(&worker.runtime_id)?;
runtime
.coordinate_worker_stop(&worker.worker_id, request)
.map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: diagnostic.code,
message: diagnostic.message,
})
}
pub fn worker_stop_outcome(
&self,
worker: &RuntimeWorkerRef,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &worker.runtime_id)?;
validate_backend_identifier("worker_id", &worker.worker_id)?;
let runtime = self.runtime(&worker.runtime_id)?;
runtime
.worker_stop_outcome(&worker.worker_id, request)
.map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: worker.runtime_id.clone(),
code: diagnostic.code,
message: diagnostic.message,
})
}
pub fn stop_worker(
&self,
worker: &RuntimeWorkerRef,
@@ -3223,6 +3295,56 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn coordinate_worker_stop(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
let worker_ref = self.worker_ref(worker_id).ok_or_else(|| {
diagnostic(
"embedded_worker_id_invalid",
HostDiagnosticSeverity::Error,
"Worker ID could not be resolved".into(),
)
})?;
let outcome = self
.runtime
.coordinate_worker_stop(
&worker_ref,
runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
},
)
.map_err(|error| embedded_runtime_diagnostic(&error))?;
runtime_contract_convert(outcome)
}
fn worker_stop_outcome(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
let worker_ref = self.worker_ref(worker_id).ok_or_else(|| {
diagnostic(
"embedded_worker_id_invalid",
HostDiagnosticSeverity::Error,
"Worker ID could not be resolved".into(),
)
})?;
let outcome = self
.runtime
.worker_stop_outcome(
&worker_ref,
runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
},
)
.map_err(|error| embedded_runtime_diagnostic(&error))?;
runtime_contract_convert(outcome)
}
fn stop_worker(
&self,
worker_id: &str,
@@ -3250,8 +3372,15 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
),
);
};
match self.runtime.stop_worker(&worker_ref, request.reason) {
Ok(_) => WorkerLifecycleResult {
match self.runtime.stop_worker_request(
&worker_ref,
runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
},
) {
Ok(response) => WorkerLifecycleResult {
replayed: response.replayed,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
diagnostics: Vec::new(),
@@ -3295,6 +3424,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
};
match self.runtime.cancel_worker(&worker_ref, request.reason) {
Ok(_) => WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
diagnostics: Vec::new(),
@@ -4899,6 +5029,7 @@ impl RemoteWorkerRuntime {
response: worker_runtime::catalog::WorkerLifecycleAck,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: response.replayed,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
diagnostics: vec![diagnostic(
@@ -5574,6 +5705,40 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn coordinate_worker_stop(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
let worker_id = worker_id.to_string();
let request = runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
};
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.coordinate_worker_stop(worker_id, request).await },
)
}
fn worker_stop_outcome(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> Result<runtime_api::WorkerStopOutcome, RuntimeDiagnostic> {
let worker_id = worker_id.to_string();
let request = runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
};
self.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move { client.worker_stop_outcome(worker_id, request).await },
)
}
fn stop_worker(
&self,
worker_id: &str,
@@ -5581,6 +5746,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
) -> WorkerLifecycleResult {
let body = runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
};
let worker_id_owned = worker_id.to_string();
match self
@@ -5606,6 +5772,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
) -> WorkerLifecycleResult {
let body = runtime_api::WorkerLifecycleRequest {
reason: request.reason,
stop_intent: request.stop_intent,
};
let worker_id_owned = worker_id.to_string();
match self
@@ -6399,6 +6566,7 @@ fn embedded_lifecycle_rejected(
diagnostic: RuntimeDiagnostic,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Rejected,
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
diagnostics: vec![diagnostic],
@@ -6411,6 +6579,7 @@ fn remote_lifecycle_rejected(
diagnostic: RuntimeDiagnostic,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Rejected,
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
diagnostics: vec![diagnostic],
@@ -6484,6 +6653,26 @@ fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
match error {
EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic(
"restore_admission_busy",
HostDiagnosticSeverity::Warning,
"Worker lifecycle admission is busy; retry the same Restore intent".into(),
),
EmbeddedRuntimeError::StopAdmissionBusy { .. } => diagnostic(
"stop_admission_busy",
HostDiagnosticSeverity::Warning,
"Worker lifecycle admission is busy; retry the same stop intent".into(),
),
EmbeddedRuntimeError::StopObservationConflict { .. } => diagnostic(
"stop_observation_conflict",
HostDiagnosticSeverity::Warning,
"Stop observation changed; reload before a new intent".into(),
),
EmbeddedRuntimeError::StopRequestConflict { .. } => diagnostic(
"stop_request_conflict",
HostDiagnosticSeverity::Warning,
"Stop request ID was already used with different input".into(),
),
EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic(
"restore_observation_conflict",
HostDiagnosticSeverity::Warning,
@@ -7288,6 +7477,7 @@ mod tests {
assert!(value.get("worker").is_none());
let lifecycle = WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new("arcadia", "30"),
diagnostics: Vec::new(),
+432 -25
View File
@@ -29,6 +29,7 @@ mod backend_job_profile;
mod chat;
mod external_operation_audit;
mod subject_authorization;
mod subject_body;
mod subjektiv_deletion;
mod subjektiv_job;
mod subjektiv_journal;
@@ -659,6 +660,77 @@ impl WorkdirSessionRegistry {
.collect()
}
// Stop owns the concrete provider sessions observed before dispatch, not
// every session later registered under the same restored Worker identity.
fn observe_worker(&self, worker: &RuntimeWorkerRef) -> Vec<RegisteredWorkdirSession> {
let mut observed = Vec::new();
if let Some(session) = self.attachments.get(worker) {
observed.push(RegisteredWorkdirSession::Attachment {
worker: worker.clone(),
session: session.clone(),
});
}
observed.extend(
self.commands
.iter()
.filter_map(|((owner, handle), session)| {
(owner == worker).then(|| RegisteredWorkdirSession::Command {
worker: worker.clone(),
external_handle: handle.clone(),
session: session.clone(),
})
}),
);
observed
}
fn take_observed(
&mut self,
observed: &[RegisteredWorkdirSession],
) -> Vec<RegisteredWorkdirSession> {
observed
.iter()
.filter_map(|item| match item {
RegisteredWorkdirSession::Attachment { worker, session } => self
.attachments
.get(worker)
.is_some_and(|current| Arc::ptr_eq(current, session))
.then(|| {
self.attachments.remove(worker).map(|session| {
RegisteredWorkdirSession::Attachment {
worker: worker.clone(),
session,
}
})
})
.flatten(),
RegisteredWorkdirSession::Command {
worker,
external_handle,
session,
} => {
let key = (worker.clone(), external_handle.clone());
self.commands
.get(&key)
.is_some_and(|current| {
Arc::ptr_eq(&current.source, &session.source)
&& current.provider_handle == session.provider_handle
})
.then(|| {
self.commands.remove(&key).map(|session| {
RegisteredWorkdirSession::Command {
worker: worker.clone(),
external_handle: external_handle.clone(),
session,
}
})
})
.flatten()
}
})
.collect()
}
fn take_worker(&mut self, worker: &RuntimeWorkerRef) -> Vec<RegisteredWorkdirSession> {
let mut sessions = Vec::new();
if let Some(session) = self.attachments.remove(worker) {
@@ -759,6 +831,17 @@ async fn close_registered_workdir_sessions(
}
}
async fn close_observed_worker_workdir_sessions(
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
observed: &[RegisteredWorkdirSession],
) -> std::result::Result<(), String> {
let registered = registry
.lock()
.map_err(|_| "Workdir session registry was poisoned".to_string())?
.take_observed(observed);
close_registered_workdir_sessions(registry, registered).await
}
async fn close_worker_workdir_sessions(
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
worker: &RuntimeWorkerRef,
@@ -2220,6 +2303,7 @@ impl WorkerRemovalService {
return (false, diagnostics);
};
let lifecycle_request = WorkerLifecycleRequest {
stop_intent: None,
reason: Some("Backend spawn finalize failed; compensating Runtime Worker".to_string()),
ticket_assignment: None,
};
@@ -5513,6 +5597,7 @@ impl WorkspaceApi {
match self.runtime.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(format!(
"Backend Job attempt {attempt_id} reached terminal state"
)),
@@ -6580,6 +6665,15 @@ impl WorkspaceApi {
&self,
worker: &RuntimeWorkerRef,
request: server_api::WorkerRestoreRequest,
) -> ApiResult<InternalWorkerRestoreResult> {
self.restore_workspace_worker_with_subject(worker, request, None)
}
fn restore_workspace_worker_with_subject(
&self,
worker: &RuntimeWorkerRef,
request: server_api::WorkerRestoreRequest,
expected_subject: Option<(&str, &server_api::SubjectBodyRef)>,
) -> ApiResult<InternalWorkerRestoreResult> {
if request.request_id.trim().is_empty()
|| request.request_id.len() > 512
@@ -6591,6 +6685,19 @@ impl WorkspaceApi {
)
.into());
}
let subject_gate = expected_subject
.map(|(subject, _)| subjektiv_journal::lifecycle_lock(self, subject))
.transpose()?;
let subject_guard = subject_gate
.as_ref()
.map(|gate| {
gate.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))
})
.transpose()?;
if let Some((subject, expected)) = expected_subject {
subject_body::require_expected(self, subject, Some(expected))?;
}
// Auth is checked by WorkspaceWorker before this read-only owner dispatch.
// Existing owners reach receipts/reconciliation before NEW launch eligibility.
let (recovered, owned_preparation) = self
@@ -6608,6 +6715,24 @@ impl WorkspaceApi {
result.worker = self.current_restore_summary(worker, result.worker.is_some());
return Ok(result);
}
if expected_subject.is_some() && owned_preparation.is_none() {
let live = self
.runtime
.worker(worker)
.map_err(RuntimeRegistryError::into_error)?;
if live.restore_observation_token.as_deref()
== Some(request.expected_observation_token.as_str())
&& live.availability
== protocol::subscription::SubscriptionWorkerAvailability::Observed
&& live.worker_state.is_some()
{
return Ok(InternalWorkerRestoreResult {
state: server_api::WorkerRestoreState::Accepted,
worker: Some(live),
diagnostics: Vec::new(),
});
}
}
let preparation = if let Some(preparation) = owned_preparation {
preparation
} else {
@@ -6642,6 +6767,10 @@ impl WorkspaceApi {
Error::Config("Runtime did not retain admitted Restore preparation".into())
})?
};
// Runtime has retained the exact Restore owner/preparation. Release the
// Subject gate before execution can call back into Session/Journal APIs.
drop(subject_guard);
drop(subject_gate);
let mut restored = self
.runtime
.restore_worker(
@@ -7703,6 +7832,16 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::subjektiv_subject_get(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_body_get(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_body_start(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_body_stop(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_body_stop_outcome(service.clone()))
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
.merge(server_api::server_api_axum::subjektiv_resident_surface(
service.clone(),
@@ -8335,6 +8474,12 @@ fn worker_lifecycle_request_from_api(
request: server_api::RuntimeWorkerLifecycleRequest,
) -> WorkerLifecycleRequest {
WorkerLifecycleRequest {
stop_intent: request
.stop_intent
.map(|intent| runtime_api::WorkerStopIntent {
expected_observation_token: intent.expected_observation_token,
request_id: intent.request_id,
}),
reason: request.reason,
ticket_assignment: request
.ticket_assignment
@@ -8443,6 +8588,7 @@ fn worker_lifecycle_result_to_api(
result: WorkerLifecycleResult,
) -> server_api::RuntimeWorkerLifecycleResult {
server_api::RuntimeWorkerLifecycleResult {
replayed: result.replayed,
state: result.state.into(),
runtime_id: result.worker.runtime_id,
worker_id: result.worker.worker_id,
@@ -10074,6 +10220,81 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_body_get(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
) -> std::result::Result<server_api::SubjectBodyResponse, server_api::RepositoryApiError> {
subject_body::get(
self.workspace_api()?.clone(),
context,
workspace_id,
subject_id,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_body_start(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
request: server_api::SubjectBodyStartRequest,
) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::RepositoryApiError>
{
subject_body::start(
self.workspace_api()?.clone(),
context,
workspace_id,
subject_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_body_stop(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
request: server_api::SubjectBodyStopRequest,
) -> std::result::Result<server_api::SubjectBodyMutationResponse, server_api::RepositoryApiError>
{
subject_body::stop(
self.workspace_api()?.clone(),
context,
workspace_id,
subject_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_body_stop_outcome(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
subject_id: String,
request: server_api::SubjectBodyStopRequest,
) -> std::result::Result<
server_api::SubjectBodyStopOutcomeResponse,
server_api::RepositoryApiError,
> {
subject_body::stop_outcome(
self.workspace_api()?.clone(),
context,
workspace_id,
subject_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_behavior_update(
&self,
workspace_id: String,
@@ -16266,6 +16487,7 @@ async fn compensate_manual_ticket_worker_binding(
match api.runtime.stop_worker(
worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(
"roll back failed manual Worker assignment capability binding".to_string(),
),
@@ -16595,6 +16817,7 @@ async fn start_manual_ticket_worker_assignment_on_host(
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(format!(
"bind authoritative Workdir capabilities for Ticket {} manual Worker assignment",
record.ticket_id
@@ -17329,6 +17552,7 @@ async fn cancel_ticket_worker(
match api.runtime.cancel_worker(
worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(format!("Ticket implementation cancelled: {reason}")),
ticket_assignment: None,
},
@@ -23815,16 +24039,28 @@ fn subjektiv_subject_response(
let memory_fingerprint = open_subjektiv_store(api)?
.memory_fingerprint(&subject.id)
.map_err(subjektiv_store_error)?;
let current_worker = crate::subjektiv::subject_worker_singleton_key(&subject.id)
.ok()
.and_then(|key| {
api.store
.current_worker_singleton_owner(&api.config.workspace_id, &key)
.ok()
.flatten()
})
.and_then(|lease| api.runtime.worker(&lease.worker).ok())
.map(worker_launch_worker_summary);
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
// Only an authoritative absent owner means no body. A failed lookup must
// not invite a second launch by presenting an occupied singleton as empty.
let owner = api
.store
.current_worker_singleton_owner(&api.config.workspace_id, &singleton_key)?;
let current_body = owner.as_ref().map(subject_body::reference);
let (current_worker, current_worker_diagnostics) = match owner {
None => (None, vec![]),
Some(lease) => match api.runtime.worker(&lease.worker) {
Ok(worker) => (Some(worker_launch_worker_summary(worker)), vec![]),
Err(error) => (
None,
vec![server_api::Diagnostic {
code: "subject_body_observation_unavailable".into(),
severity: server_api::DiagnosticSeverity::Warning,
message: sanitize_backend_error(&error.message()),
}],
),
},
};
Ok(server_api::SubjektivSubjectResponse {
id: subject.id,
role: subject.role.as_str().to_string(),
@@ -23838,6 +24074,8 @@ fn subjektiv_subject_response(
},
created_at: subject.created_at,
updated_at: subject.updated_at,
current_body,
current_worker_diagnostics,
current_worker,
})
}
@@ -37515,13 +37753,24 @@ fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagn
fn sync_linked_workdir_after_worker_stop(
api: &WorkspaceApi,
runtime_id: &str,
worker_record: &WorkerRegistryRecord,
worker: &RuntimeWorkerRef,
observed_links: &[WorkerWorkdirLinkRecord],
) -> ApiResult<()> {
let links = api
// The caller holds the Worker session fence. Rebinding may have won before
// that fence was reacquired; never project its new connection for an old stop.
let current = api
.store
.list_worker_workdir_links(&api.config.workspace_id, &worker_record.worker)?;
for link in links {
.list_worker_workdir_links(api.workspace_id(), worker)?;
for link in observed_links.iter().filter(|observed| {
observed.unlinked_at.is_none()
&& current.iter().any(|current| {
current.unlinked_at.is_none()
&& current.connection_id == observed.connection_id
&& current.alias == observed.alias
&& current.workdir_id == observed.workdir_id
})
}) {
let runtime_id = &worker.runtime_id;
let result = api
.runtime
.working_directory(runtime_id, link.workdir_id.as_str())
@@ -42274,6 +42523,7 @@ mod tests {
callback();
}
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
diagnostics: Vec::new(),
@@ -42286,6 +42536,7 @@ mod tests {
_request: WorkerLifecycleRequest,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
replayed: false,
state: InternalWorkerOperationState::Accepted,
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
diagnostics: Vec::new(),
@@ -42955,15 +43206,23 @@ mod tests {
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
let stopped = Arc::new(tokio::sync::Notify::new());
let notification = stopped.clone();
let runtime = WorkdirlessFixtureRuntime {
stop_callback: Some(Arc::new(move || notification.notify_one())),
..Default::default()
};
api.runtime.register_or_replace(runtime.clone());
let identity = RuntimeWorkerRef::new(
WorkdirlessFixtureRuntime::RUNTIME_ID,
Uuid::now_v7().to_string(),
);
let cleanup_lock = current_worker_session_lock(&api, &identity);
let session_guard = Arc::new(Mutex::new(None));
let held_guard = session_guard.clone();
let runtime = WorkdirlessFixtureRuntime {
stop_callback: Some(Arc::new(move || {
// Capture finished before dispatch. Hold the same fence only
// after admission, so cancellation still precedes cleanup.
*held_guard.lock().unwrap() = Some(cleanup_lock.clone().try_lock_owned().unwrap());
notification.notify_one();
})),
..Default::default()
};
api.runtime.register_or_replace(runtime.clone());
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
@@ -42997,11 +43256,8 @@ mod tests {
.lock()
.unwrap()
.insert_attachment(identity.clone(), session);
// An actual session fence prevents cleanup until after cancellation;
// The callback takes the session fence after capture and before ACK;
// the Runtime receipt is the synchronization event, not an elapsed delay.
let session_guard = current_worker_session_lock(&api, &identity)
.lock_owned()
.await;
let worker =
WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
let waiter = tokio::spawn(async move {
@@ -43009,6 +43265,7 @@ mod tests {
.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -43033,7 +43290,7 @@ mod tests {
.attachments
.contains_key(&identity)
);
drop(session_guard);
drop(session_guard.lock().unwrap().take());
tokio::time::timeout(
std::time::Duration::from_secs(2),
api.host_executors.host().unwrap().drain(),
@@ -43052,6 +43309,145 @@ mod tests {
);
}
#[tokio::test(flavor = "current_thread")]
async fn fresh_stop_does_not_close_sessions_rebound_before_cleanup() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("alive"), "retained").unwrap();
let api = test_api(directory.path()).await;
api.worker_projection.shutdown_async().await;
api.store.db_executor().drain().await.unwrap();
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
let identity = RuntimeWorkerRef::new(
WorkdirlessFixtureRuntime::RUNTIME_ID,
Uuid::now_v7().to_string(),
);
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
worker: identity.clone(),
display_name: "fresh stop resource lifetime".into(),
profile: None,
retention_state: "normal".into(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
let session = || -> WorkdirSessionHandle {
Arc::new(workdir::LocalWorkdirSession::new(
manifest::Scope::writable(directory.path()).unwrap(),
directory.path().to_path_buf(),
))
};
let old_attachment = session();
let old_command = session();
let new_attachment = session();
let new_command = session();
let old_handle = {
let mut registry = api.workdir_sessions.lock().unwrap();
registry.insert_attachment(identity.clone(), old_attachment.clone());
registry.register_command(
identity.clone(),
"checkout".into(),
old_command.clone(),
CommandHandle("old-provider".into()),
)
};
let dispatched = Arc::new(tokio::sync::Notify::new());
let notification = dispatched.clone();
let cleanup_lock = current_worker_session_lock(&api, &identity);
let held = Arc::new(Mutex::new(None));
let held_by_callback = held.clone();
api.runtime.register_or_replace(WorkdirlessFixtureRuntime {
stop_callback: Some(Arc::new(move || {
// A genuine fresh-ACK path, not historical receipt replay. Capture
// must release this fence before Runtime dispatch/callbacks.
*held_by_callback.lock().unwrap() =
Some(cleanup_lock.clone().try_lock_owned().unwrap());
notification.notify_one();
})),
..Default::default()
});
let worker =
WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
let waiter = tokio::spawn(async move {
worker
.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest::default(),
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), dispatched.notified())
.await
.unwrap();
let guard = held.lock().unwrap().take().unwrap();
// The same fence and registry mutations used by restore's first Workdir
// operation/rebind win before the old stop's delayed cleanup.
let new_handle = {
let mut registry = api.workdir_sessions.lock().unwrap();
let replaced = registry
.insert_attachment(identity.clone(), new_attachment.clone())
.unwrap();
assert!(Arc::ptr_eq(&replaced, &old_attachment));
registry.register_command(
identity.clone(),
"checkout".into(),
new_command.clone(),
CommandHandle("new-provider".into()),
)
};
old_attachment.close().await.unwrap();
drop(guard);
let result = tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(result.state, InternalWorkerOperationState::Accepted);
assert!(!result.replayed);
{
let registry = api.workdir_sessions.lock().unwrap();
assert!(Arc::ptr_eq(
registry.attachments.get(&identity).unwrap(),
&new_attachment
));
assert!(
registry
.command(&identity, "checkout", &old_handle)
.is_none()
);
assert!(
registry
.command(&identity, "checkout", &new_handle)
.is_some()
);
}
for session in [&new_attachment, &new_command] {
session
.stat(workdir::StatRequest {
path: workdir::WorkdirPath::new("alive").unwrap(),
})
.await
.unwrap();
}
assert!(
old_command
.stat(workdir::StatRequest {
path: workdir::WorkdirPath::new("alive").unwrap()
})
.await
.is_err()
);
// Ordinary revocation still releases the surviving current resources.
close_worker_workdir_sessions(&api.workdir_sessions, &identity)
.await
.unwrap();
}
#[tokio::test]
async fn command_session_survives_attachment_refresh_until_worker_revocation() {
let directory = tempfile::tempdir().unwrap();
@@ -45270,6 +45666,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("exercise logical attachment reconciliation".to_string()),
ticket_assignment: None,
},
@@ -45407,6 +45804,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("production Orchestrator restore regression test".to_string()),
ticket_assignment: None,
},
@@ -46511,6 +46909,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("singleton restore test".to_string()),
ticket_assignment: None,
},
@@ -46968,6 +47367,7 @@ mod tests {
.stop_worker(
&target,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("prepare archive revocation regression".to_string()),
ticket_assignment: None,
},
@@ -48063,6 +48463,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("test subject Worker restart".to_string()),
ticket_assignment: None,
},
@@ -49820,6 +50221,7 @@ mod tests {
.stop_worker(
&forged_internal.worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("remove forged Orchestrator claimant".to_string()),
ticket_assignment: None,
},
@@ -57300,6 +57702,7 @@ mod tests {
.stop_worker(
&singleton_worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("replace completed Memory singleton".to_string()),
ticket_assignment: None,
},
@@ -61626,6 +62029,7 @@ mod tests {
.stop_worker(
&target,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("prepare WorkerRemove regression".to_string()),
ticket_assignment: None,
},
@@ -65258,6 +65662,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("prepare singleton cleanup".to_string()),
ticket_assignment: None,
},
@@ -65383,6 +65788,7 @@ mod tests {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("prepare cleanup projection regression".to_string()),
ticket_assignment: None,
},
@@ -69006,6 +69412,7 @@ mod tests {
.stop_worker(
&worker_ref,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("retire Worker after rejected automatic restore".to_string()),
ticket_assignment: None,
},
@@ -885,6 +885,7 @@ pub(super) async fn interrupt_subject_workers(
let cancellation = match api.host_executors.host() {
Ok(host) => host.run(move || -> ApiResult<_> {
Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest {
stop_intent: None,
reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None,
}).map_err(RuntimeRegistryError::into_error)?)
}).await,
@@ -0,0 +1,522 @@
//! Subject adapters derive targets from the ordinary singleton and dispatch ordinary
//! Worker lifecycle operations. Receipts are historical; observations are read separately.
use super::*;
use server_api::{
SubjectBodyDisposition, SubjectBodyMutationResponse, SubjectBodyObservation, SubjectBodyRef,
SubjectBodyResponse, SubjectBodyStartRequest, SubjectBodyStopRequest,
};
fn diagnostic(code: &str, message: String) -> server_api::Diagnostic {
server_api::Diagnostic {
code: code.into(),
severity: server_api::DiagnosticSeverity::Warning,
message: sanitize_backend_error(&message),
}
}
fn require_subject(
api: &WorkspaceApi,
subject: &str,
) -> ApiResult<crate::subjektiv::SubjectRecord> {
let record = open_subjektiv_store(api)?
.subject(subject)
.map_err(subjektiv_store_error)?
.ok_or_else(|| Error::SubjektivSubjectNotFound(subject.into()))?;
if record.state != crate::subjektiv::SubjectState::Active
|| subjektiv_deletion::pending(api, subject)?
{
return Err(Error::WorkspacePermissionDenied(
"Subject is deleted, being deleted or no longer active".into(),
)
.into());
}
Ok(record)
}
fn current(
api: &WorkspaceApi,
subject: &str,
) -> ApiResult<Option<crate::store::WorkerSingletonLease>> {
require_subject(api, subject)?;
let key = crate::subjektiv::subject_worker_singleton_key(subject)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
Ok(api
.store
.current_worker_singleton_owner(api.workspace_id(), &key)?)
}
pub(super) fn reference(lease: &crate::store::WorkerSingletonLease) -> SubjectBodyRef {
SubjectBodyRef {
runtime_id: lease.worker.runtime_id.clone(),
worker_id: lease.worker.worker_id.clone(),
generation: lease.generation.to_string(),
}
}
/// Call while holding the existing Subject lifecycle gate through Runtime admission.
pub(super) fn require_expected(
api: &WorkspaceApi,
subject: &str,
expected: Option<&SubjectBodyRef>,
) -> ApiResult<Option<crate::store::WorkerSingletonLease>> {
let lease = current(api, subject)?;
if lease.as_ref().map(reference).as_ref() != expected {
return Err(Error::RepositoryConflict(
"Subject body changed; reload before a new lifecycle intent".into(),
)
.into());
}
Ok(lease)
}
pub(super) fn snapshot(api: &WorkspaceApi, subject: &str) -> ApiResult<SubjectBodyResponse> {
let lease = current(api, subject)?;
let observation = match lease.as_ref() {
None => SubjectBodyObservation::Absent,
Some(lease) => {
let observe = || -> ApiResult<server_api::WorkerSummary> {
let live = api
.runtime
.worker(&lease.worker)
.map_err(RuntimeRegistryError::into_error)?;
let record = api
.store
.get_worker_registry(api.workspace_id(), &lease.worker)?
.ok_or_else(|| Error::UnknownWorker {
worker: lease.worker.clone(),
})?;
let links = api
.store
.list_worker_workdir_links(api.workspace_id(), &lease.worker)?;
let workdirs = api.store.list_workdir_registry(api.workspace_id(), 500)?;
project_worker_registry_projection(api, Some(&live), &record, links, &workdirs)
};
match observe() {
Ok(worker) => SubjectBodyObservation::Available { worker },
Err(error) => SubjectBodyObservation::Unavailable {
diagnostics: vec![diagnostic(
"subject_body_observation_unavailable",
error.error.to_string(),
)],
},
}
}
};
if current(api, subject)? != lease {
return Err(Error::RepositoryConflict(
"Subject body changed during observation; reload its current state".into(),
)
.into());
}
Ok(SubjectBodyResponse {
workspace_id: api.workspace_id().into(),
subject_id: subject.into(),
current: lease.as_ref().map(reference),
observation,
can_start: false,
can_stop: false,
})
}
async fn account_context(
api: &WorkspaceApi,
context: &server_api::ServerRequestContext,
workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> {
validate_workspace_scope(api, workspace)?;
let actor = subject_authorization::administration_actor(context)?;
require_workspace_owner(api, workspace, actor, "Subject body lifecycle").await?;
let authority = WorkerOperationContext::from_request_async(api, context)
.await
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
authority.authorize_workspace(api).await?;
Ok((actor.account_id.clone(), authority))
}
pub(super) async fn get(
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace: String,
subject: String,
) -> ApiResult<SubjectBodyResponse> {
account_context(&api, &context, &workspace).await?;
api.host_executors
.host()?
.run(move || {
let mut body = snapshot(&api, &subject)?;
body.can_start = true;
body.can_stop = true;
Ok(body)
})
.await
}
fn validate_request_id(id: &str) -> ApiResult<()> {
if id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
return Err(Error::InvalidInput(
"request_id must contain 1..128 bytes without control characters".into(),
)
.into());
}
Ok(())
}
fn operation_id(account: &str, workspace: &str, subject: &str, request: &str) -> String {
let bytes = serde_json::to_vec(&(account, workspace, subject, request))
.expect("lifecycle identity is text");
let digest = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("subject-body-start:{digest}")
}
async fn finish(
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace: String,
subject: String,
request_id: String,
disposition: SubjectBodyDisposition,
diagnostics: Vec<server_api::Diagnostic>,
) -> SubjectBodyMutationResponse {
let body = get(api, context, workspace, subject).await.ok();
SubjectBodyMutationResponse {
request_id,
disposition,
diagnostics,
body,
}
}
fn stop_request(request: &SubjectBodyStopRequest) -> WorkerLifecycleRequest {
WorkerLifecycleRequest {
reason: Some("Stopped from the Subject screen; Subject data is retained".into()),
ticket_assignment: None,
stop_intent: Some(runtime_api::WorkerStopIntent {
expected_observation_token: request.expected_observation_token.clone(),
request_id: request.request_id.clone(),
}),
}
}
pub(super) async fn stop_outcome(
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace: String,
subject: String,
request: SubjectBodyStopRequest,
) -> ApiResult<server_api::SubjectBodyStopOutcomeResponse> {
validate_request_id(&request.request_id)?;
let (_, authority) = account_context(&api, &context, &workspace).await?;
let lookup_api = api.clone();
let lookup_subject = subject.clone();
let expected = request.expected_current.clone();
let lease = api
.store
.db_executor()
.run(move || {
require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| {
ApiError::from(Error::RepositoryConflict(
"Subject has no current body".into(),
))
})
})
.await?;
let worker =
WorkspaceWorker::resolve_async(&api, &lease.worker.runtime_id, &lease.worker.worker_id)
.await?;
let result = worker
.stop_outcome_current_subject(
&authority,
stop_request(&request),
subject.clone(),
request.expected_current,
)
.await?;
let outcome = match result {
runtime_api::WorkerStopOutcome::NotFound => server_api::SubjectBodyStopOutcome::NotFound,
runtime_api::WorkerStopOutcome::Pending => server_api::SubjectBodyStopOutcome::Pending,
runtime_api::WorkerStopOutcome::Stopped { .. } => {
server_api::SubjectBodyStopOutcome::Stopped
}
};
let body = get(api, context, workspace, subject).await.ok();
Ok(server_api::SubjectBodyStopOutcomeResponse {
request_id: request.request_id,
outcome,
body,
})
}
pub(super) async fn stop(
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace: String,
subject: String,
request: SubjectBodyStopRequest,
) -> ApiResult<SubjectBodyMutationResponse> {
validate_request_id(&request.request_id)?;
let (_, authority) = account_context(&api, &context, &workspace).await?;
let lookup_api = api.clone();
let lookup_subject = subject.clone();
let expected = request.expected_current.clone();
let lease = api
.store
.db_executor()
.run(move || {
require_expected(&lookup_api, &lookup_subject, Some(&expected))?.ok_or_else(|| {
ApiError::from(Error::RepositoryConflict(
"Subject has no current body".into(),
))
})
})
.await?;
let worker =
WorkspaceWorker::resolve_async(&api, &lease.worker.runtime_id, &lease.worker.worker_id)
.await?;
let result = worker
.stop_current_subject(
&authority,
stop_request(&request),
subject.clone(),
request.expected_current,
)
.await;
let (disposition, diagnostics) = match result {
Ok(result) => (
if result.state == InternalWorkerOperationState::Accepted {
SubjectBodyDisposition::Accepted
} else {
SubjectBodyDisposition::Unknown
},
result.diagnostics.into_iter().map(Into::into).collect(),
),
Err(error)
if matches!(
error.error,
Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_)
| Error::RestoreObservationConflict
) =>
{
return Err(error);
}
// A post-dispatch failure may have followed a real stop. Keep the exact intent.
Err(error) => (
SubjectBodyDisposition::Unknown,
vec![diagnostic(
"subject_body_stop_outcome_unknown",
error.error.to_string(),
)],
),
};
Ok(finish(
api,
context,
workspace,
subject,
request.request_id,
disposition,
diagnostics,
)
.await)
}
pub(super) async fn start(
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace: String,
subject: String,
request: SubjectBodyStartRequest,
) -> ApiResult<SubjectBodyMutationResponse> {
validate_request_id(&request.request_id)?;
let (account, authority) = account_context(&api, &context, &workspace).await?;
if let Some(expected) = request.expected_current.as_ref() {
let observed = get(
api.clone(),
context.clone(),
workspace.clone(),
subject.clone(),
)
.await?;
if observed.current.as_ref() != Some(expected) {
return Err(
Error::RepositoryConflict("Subject body changed before start".into()).into(),
);
}
let SubjectBodyObservation::Available { .. } = observed.observation else {
return Err(Error::RepositoryConflict(
"Current body cannot be observed; do not create a replacement".into(),
)
.into());
};
let token = request.expected_observation_token.clone().ok_or_else(|| {
Error::InvalidInput(
"Starting an existing body requires its observed Runtime token".into(),
)
})?;
// Even a live-body reuse checks Runtime's existing request owner first;
// changed input cannot bypass a retained Restore receipt via a fresh GET.
let worker =
WorkspaceWorker::resolve_async(&api, &expected.runtime_id, &expected.worker_id).await?;
let result = worker
.restore_current_subject(
&authority,
server_api::WorkerRestoreRequest {
expected_observation_token: token,
request_id: operation_id(&account, &workspace, &subject, &request.request_id),
},
subject.clone(),
expected.clone(),
)
.await;
let (disposition, diagnostics) = match result {
Ok(result) => (
match result.state {
server_api::WorkerRestoreState::Accepted => SubjectBodyDisposition::Accepted,
server_api::WorkerRestoreState::Rejected
| server_api::WorkerRestoreState::RolledBack
| server_api::WorkerRestoreState::Superseded => {
SubjectBodyDisposition::Rejected
}
server_api::WorkerRestoreState::ReconciliationRequired => {
SubjectBodyDisposition::Unknown
}
},
result.diagnostics.into_iter().map(Into::into).collect(),
),
Err(error)
if matches!(
error.error,
Error::RepositoryConflict(_)
| Error::WorkspacePermissionDenied(_)
| Error::RestoreObservationConflict
) =>
{
return Err(error);
}
Err(error) => (
SubjectBodyDisposition::Unknown,
vec![diagnostic(
"subject_body_restore_outcome_unknown",
error.error.to_string(),
)],
),
};
return Ok(finish(
api,
context,
workspace,
subject,
request.request_id,
disposition,
diagnostics,
)
.await);
}
if request.expected_observation_token.is_some() {
return Err(
Error::InvalidInput("An absent body has no Runtime observation token".into()).into(),
);
}
let create_api = api.clone();
let create_context = context.clone();
let create_subject = subject.clone();
let create_workspace = workspace.clone();
let request_id = request.request_id.clone();
let operation = operation_id(&account, &workspace, &subject, &request_id);
let result = run_host_workflow(api.host_executors.host()?, async move {
let subject_record = require_subject(&create_api, &create_subject)?;
// An exact creation retry uses the same ordinary reservation, including its
// original Runtime. It never changes an unknown request into a new launch.
let reserved_runtime = create_api
.config_store
.reserved_worker_create_runtime(&create_workspace, &operation)?;
if reserved_runtime.is_none() {
require_expected(&create_api, &create_subject, None)?;
}
let runtime_id = match reserved_runtime {
Some(runtime) => runtime,
None => {
let runtimes = create_api.runtime.list_runtimes(100);
runtimes
.items
.iter()
.filter(|runtime| runtime.worker_creation_available)
.min_by_key(|runtime| {
(
runtime.runtime_id != EMBEDDED_WORKER_RUNTIME_ID,
runtime.runtime_id.clone(),
)
})
.map(|runtime| runtime.runtime_id.clone())
.ok_or_else(|| {
Error::Config("No Runtime is available to start this Subject".into())
})?
}
};
let input = serde_json::to_vec(&(&account, &create_workspace, &create_subject, &request))
.map_err(|error| Error::InvalidInput(error.to_string()))?;
let fingerprint = Sha256::digest(input)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
create_workspace_worker_authenticated_on_host(
create_api,
contract_request_headers(&create_context)
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?,
server_api::CreateWorkspaceWorkerRequest {
runtime_id,
display_name: subject_record.role.as_str().into(),
singleton_key: None,
profile: Some("builtin:async-chat".into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
subject_id: create_subject,
}),
},
control_operation_id: None,
execution_grant: None,
},
Some(WorkerControlOperation {
operation_id: operation,
input_fingerprint: format!("sha256:{fingerprint}"),
}),
Some(&create_context),
)
.await
})
.await;
let (disposition, diagnostics) = match result {
Ok(Json(result)) => (SubjectBodyDisposition::Accepted, result.diagnostics),
Err(error)
if matches!(
error.error,
Error::RepositoryConflict(_) | Error::WorkspacePermissionDenied(_)
) =>
{
return Err(error);
}
Err(error) => (
SubjectBodyDisposition::Unknown,
vec![diagnostic(
"subject_body_start_outcome_unknown",
error.error.to_string(),
)],
),
};
Ok(finish(
api,
context,
workspace,
subject,
request_id,
disposition,
diagnostics,
)
.await)
}
@@ -240,6 +240,7 @@ async fn remove_worker(
.stop_worker(
worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(format!("Explicit Subject deletion {operation}")),
ticket_assignment: None,
},
@@ -113,6 +113,7 @@ pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -
.stop_worker(
&owner.worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some(
"Retire legacy subjektiv consolidator after foreground completion"
.into(),
@@ -114,6 +114,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("retention diagnostic fixture".into()),
ticket_assignment: None,
},
@@ -308,6 +308,7 @@ async fn browser_method_queue_overflow_fails_fast_and_does_not_pin_session_clean
worker.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("cleanup-after-method-overflow".into()),
ticket_assignment: None,
},
@@ -674,6 +675,7 @@ async fn assert_same_worker_reconnect_and_stop(
worker.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("cleanup-after-stalled-protocol".into()),
ticket_assignment: None,
},
@@ -116,6 +116,7 @@ async fn restore_keeps_offline_external_and_unmaterialized_repository_attachment
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -186,6 +187,7 @@ async fn restore_keeps_offline_external_and_unmaterialized_repository_attachment
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -442,6 +442,7 @@ async fn restored_runtime_parent_lists_and_inputs_existing_child() {
.stop_worker(
&parent,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("restart parent".into()),
ticket_assignment: None,
},
@@ -9,6 +9,8 @@ use super::*;
mod chat_delegation_connected;
#[path = "chat_tests.rs"]
mod chat_tests;
#[path = "subject_body_tests.rs"]
mod subject_body_tests;
#[path = "subject_job_model_supply_tests.rs"]
mod subject_job_model_supply;
#[path = "subject_permission_management_tests.rs"]
@@ -0,0 +1,278 @@
//! Source for the authenticated Subject lifecycle adapter contract. Router/Account,
//! Subject SQLite and ordinary create reservations are real; no model or product
//! process is started. Runtime execution and restore/stop callbacks are exercised
//! by the connected suites, not inferred from this projection/admission fixture.
use super::*;
struct BodyFixture {
api: WorkspaceApi,
owner: String,
subject: String,
_dir: tempfile::TempDir,
}
impl BodyFixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "body-owner").await;
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.unwrap()
.id;
Self {
api,
owner,
subject,
_dir: dir,
}
}
fn path(&self) -> String {
format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/body",
self.subject
)
}
async fn read(&self) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "GET", &self.path(), None),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
fn reserve(&self) -> crate::store::WorkerSingletonLease {
let key = crate::subjektiv::subject_worker_singleton_key(&self.subject).unwrap();
self.api
.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
"runtime-test",
"body-start-reservation",
&"f".repeat(64),
Some(&key),
&self
.api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap(),
)
.unwrap()
.singleton
.unwrap()
}
}
#[tokio::test]
async fn body_read_retains_an_occupied_owner_when_runtime_observation_is_unavailable() {
let f = BodyFixture::new().await;
let absent = f.read().await;
assert!(absent["current"].is_null());
assert_eq!(absent["observation"]["kind"], "absent");
assert_eq!(absent["can_start"], true);
let lease = f.reserve();
let occupied = f.read().await;
assert_eq!(occupied["current"]["worker_id"], lease.worker.worker_id);
assert_eq!(
occupied["current"]["generation"],
lease.generation.to_string()
);
assert_eq!(occupied["observation"]["kind"], "unavailable");
let (status, subject) = response(
&f.api,
human_request(
&f.owner,
"GET",
f.path().strip_suffix("/body").unwrap(),
None,
),
)
.await;
assert_eq!(status, StatusCode::OK, "{subject}");
assert_eq!(subject["current_body"], occupied["current"]);
assert!(
!subject["current_worker_diagnostics"]
.as_array()
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn body_operations_reject_nonowners_and_cross_workspace_targets() {
let f = BodyFixture::new().await;
let reader = seed_test_api_token(f.api.store.as_ref(), "body-reader");
for (method, suffix, payload) in [
("GET", "", None),
(
"POST",
"/start",
Some(
json!({"request_id":"start", "expected_current":null, "expected_observation_token":null}),
),
),
] {
let (status, body) = response(
&f.api,
human_request(&reader, method, &format!("{}{suffix}", f.path()), payload),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
}
let path = f.path().replace(TEST_WORKSPACE_ID, "another-workspace");
let (status, body) = response(&f.api, human_request(&f.owner, "GET", &path, None)).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(f.read().await["current"].is_null());
}
#[tokio::test]
async fn body_stop_and_outcome_reject_a_target_from_another_subject_before_runtime_dispatch() {
let f = BodyFixture::new().await;
let lease = f.reserve();
let other = open_subjektiv_store(&f.api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("other-companion").unwrap())
.unwrap();
let path = format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/body",
other.id
);
let request = json!({"request_id":"old-screen", "expected_current":subject_body::reference(&lease), "expected_observation_token":"old-token"});
for suffix in ["/stop", "/stop/outcome"] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&format!("{path}{suffix}"),
Some(request.clone()),
),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
}
assert_eq!(
f.read().await["current"]["worker_id"],
lease.worker.worker_id
);
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn body_mutations_reject_an_old_generation_without_releasing_its_owner() {
let f = BodyFixture::new().await;
let lease = f.reserve();
let mut old = subject_body::reference(&lease);
old.generation = (lease.generation + 1).to_string();
for (suffix, payload) in [
(
"/start",
json!({"request_id":"old-start", "expected_current":old, "expected_observation_token":"old-token"}),
),
(
"/stop",
json!({"request_id":"old-stop", "expected_current":old, "expected_observation_token":"old-token"}),
),
(
"/stop/outcome",
json!({"request_id":"old-stop", "expected_current":old, "expected_observation_token":"old-token"}),
),
] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&format!("{}{suffix}", f.path()),
Some(payload),
),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
}
assert_eq!(
f.read().await["current"],
serde_json::to_value(subject_body::reference(&lease)).unwrap()
);
}
#[tokio::test]
async fn body_operations_reject_a_subject_with_deletion_already_admitted() {
let f = BodyFixture::new().await;
let lease = f.reserve();
open_subjektiv_store(&f.api)
.unwrap()
.begin_subject_deletion(&f.subject, "delete-body-fixture")
.unwrap();
let stop = json!({"request_id":"stop", "expected_current":subject_body::reference(&lease), "expected_observation_token":"observed"});
for (method, suffix, payload) in [
("GET", "", None),
(
"POST",
"/start",
Some(
json!({"request_id":"start", "expected_current":null, "expected_observation_token":null}),
),
),
("POST", "/stop", Some(stop.clone())),
("POST", "/stop/outcome", Some(stop)),
] {
let (status, body) = response(
&f.api,
human_request(&f.owner, method, &format!("{}{suffix}", f.path()), payload),
)
.await;
assert!(
matches!(status, StatusCode::FORBIDDEN | StatusCode::NOT_FOUND),
"{status}: {body}"
);
}
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject(&f.subject)
.unwrap()
.is_some()
);
assert_eq!(
f.api
.store
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &lease.key)
.unwrap(),
Some(lease)
);
}
#[tokio::test]
async fn body_mutation_json_requires_explicit_identity_and_fences() {
let f = BodyFixture::new().await;
for (suffix, payload) in [
("/start", json!({"request_id":"start"})),
(
"/stop",
json!({"request_id":"stop", "expected_observation_token":"observed"}),
),
("/stop/outcome", json!({"expected_current":null})),
] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&format!("{}{suffix}", f.path()),
Some(payload),
),
)
.await;
assert!(status.is_client_error(), "{status}: {body}");
}
assert!(f.read().await["current"].is_null());
}
@@ -817,6 +817,7 @@ async fn old_job_removal_reconciles_exact_input_then_erases_copies_across_http_r
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("Prior independent removal".into()),
ticket_assignment: None,
},
@@ -1131,6 +1132,7 @@ async fn old_body_archive_reconciles_unchanged_and_remains_retained_after_subjec
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("Earlier removal".into()),
ticket_assignment: None,
},
@@ -1,5 +1,59 @@
use super::*;
#[tokio::test]
async fn subject_current_worker_lookup_failure_is_not_reported_as_no_body() {
let workspace = tempfile::tempdir().unwrap();
let api = test_api(workspace.path()).await;
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.unwrap();
assert!(
subjektiv_subject_response(&api, subject.clone())
.unwrap()
.current_worker
.is_none()
);
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
let memory = api
.config_store
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
.unwrap();
// Real reservation SQL owns the body, but no Runtime Worker exists yet.
// This is an unavailable observation, not evidence that another launch is safe.
api.config_store
.reserve_worker_create(
TEST_WORKSPACE_ID,
"runtime-test",
"subject-start",
&"f".repeat(64),
Some(&singleton_key),
&memory,
)
.unwrap();
assert!(
api.store
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &singleton_key)
.unwrap()
.is_some()
);
let projection = subjektiv_subject_response(&api, subject.clone()).unwrap();
assert!(projection.current_body.is_some());
assert!(projection.current_worker.is_none());
assert!(
projection
.current_worker_diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "subject_body_observation_unavailable")
);
let body = subject_body::snapshot(&api, &subject.id).unwrap();
assert_eq!(body.current, projection.current_body);
assert!(matches!(
body.observation,
server_api::SubjectBodyObservation::Unavailable { .. }
));
}
#[tokio::test(flavor = "current_thread")]
async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() {
let workspace = tempfile::tempdir().unwrap();
@@ -858,6 +858,7 @@ impl RuntimeFixture {
.stop_worker(
&self.source,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("settle original source before permanent erase".into()),
ticket_assignment: None,
},
@@ -1488,6 +1489,7 @@ async fn stopped_source_pin_releases_after_saved_completion_and_removal() {
.stop_worker(
&f.source,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("archive original".into()),
ticket_assignment: None,
},
@@ -1763,6 +1765,7 @@ async fn stopped_partial_journal_recovery_fences_old_attempt_and_resumes_same_im
.stop_worker(
worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("definitive stop before checkpoint".into()),
ticket_assignment: None,
},
@@ -2130,6 +2133,7 @@ impl SubjectRemovalFixture {
.stop_worker(
&target,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("test stopped Subject removal".into()),
ticket_assignment: None,
},
@@ -1822,6 +1822,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() {
.stop_worker(
&coder,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -652,6 +652,140 @@ async fn attachment_close_cancellation_restores_remote_session_for_retry() {
cancellation_restoration(ClosePath::Attachment).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn observed_stop_close_failure_retains_old_command_without_replacing_new_attachment() {
let fixture = Fixture::new().await;
let worker = fixture.registry_owner();
let old = fixture.open().await;
let new = fixture.open().await;
let handle = fixture
.api()
.workdir_sessions
.lock()
.unwrap()
.register_command(
worker.clone(),
"checkout".into(),
old.clone(),
CommandHandle("observed-provider-command".into()),
);
let observed = fixture
.api()
.workdir_sessions
.lock()
.unwrap()
.observe_worker(&worker);
fixture
.api()
.workdir_sessions
.lock()
.unwrap()
.insert_attachment(worker.clone(), new.clone());
*fixture.transport.fault.lock().unwrap() = Some(CloseFault::Reject);
let _guard = current_worker_session_lock(fixture.api(), &worker)
.lock_owned()
.await;
assert!(
close_observed_worker_workdir_sessions(&fixture.api().workdir_sessions, &observed)
.await
.is_err()
);
{
let registry = fixture.api().workdir_sessions.lock().unwrap();
assert!(Arc::ptr_eq(
registry.attachments.get(&worker).unwrap(),
&new
));
assert!(Arc::ptr_eq(
&registry
.command(&worker, "checkout", &handle)
.unwrap()
.source,
&old
));
}
fixture.assert_live(&old).await;
fixture.assert_live(&new).await;
close_observed_worker_workdir_sessions(&fixture.api().workdir_sessions, &observed)
.await
.unwrap();
assert_eq!(fixture.transport.attempts().len(), 2);
assert_eq!(
fixture.transport.attempts()[0],
fixture.transport.attempts()[1]
);
fixture.assert_live(&new).await;
assert!(
fixture
.api()
.workdir_sessions
.lock()
.unwrap()
.command(&worker, "checkout", &handle)
.is_none()
);
close_worker_workdir_sessions(&fixture.api().workdir_sessions, &worker)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_projection_does_not_follow_a_rebound_connection_with_identical_worker_alias_and_workdir()
{
let fixture = Fixture::new().await;
let api = fixture.api();
let worker = fixture.registry_owner();
let link = WorkerWorkdirLinkRecord {
connection_id: String::new(),
workspace_id: TEST_WORKSPACE_ID.into(),
worker: worker.clone(),
workdir_id: fixture.workdir_id.clone(),
alias: "checkout".into(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
linked_at: TEST_CREATED_AT.into(),
unlinked_at: None,
};
let _guard = current_worker_session_lock(api, &worker).lock_owned().await;
let old = api.store.attach_worker_workdir(&link).unwrap();
api.store
.detach_worker_workdir_connection(
TEST_WORKSPACE_ID,
&worker,
&old.alias,
&old.connection_id,
TEST_CREATED_AT,
)
.unwrap();
let new = api.store.attach_worker_workdir(&link).unwrap();
assert_ne!(old.connection_id, new.connection_id);
let mut record = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.workdir_id)
.unwrap()
.unwrap();
record.display_name = "new binding not owned by old stop".into();
api.store.upsert_workdir_registry(&record).unwrap();
sync_linked_workdir_after_worker_stop(api, &worker, &[old]).unwrap();
assert_eq!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.workdir_id)
.unwrap()
.unwrap()
.display_name,
record.display_name
);
sync_linked_workdir_after_worker_stop(api, &worker, &[new]).unwrap();
assert_ne!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.workdir_id)
.unwrap()
.unwrap()
.display_name,
record.display_name,
"the same current connection still projects normally"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runtime_lease() {
let fixture = Fixture::new().await;
@@ -680,6 +814,7 @@ async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runt
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("prepare removal".into()),
ticket_assignment: None,
},
@@ -714,6 +714,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("Drive lifetime test".into()),
ticket_assignment: None,
},
@@ -816,6 +817,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
.stop_worker(
target,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -12,6 +12,7 @@ fn operation_input(kind: WorkerInputKind) -> WorkerInputRequest {
fn operation_lifecycle() -> WorkerLifecycleRequest {
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("operation-boundary-test".into()),
ticket_assignment: None,
}
@@ -718,6 +719,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
.is_err()
);
let lifecycle = || server_api::RuntimeWorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
};
@@ -124,6 +124,7 @@ async fn actual_workspace_multiplexer_stalled_writer_timeout_or_disconnect_recla
.stop(
&WorkerOperationContext::Backend,
WorkerLifecycleRequest {
stop_intent: None,
reason: Some("cleanup-after-multiplexer-exit".into()),
ticket_assignment: None,
},
@@ -48,6 +48,42 @@ pub(crate) enum WorkerOperationContext {
}
impl WorkerOperationContext {
fn bind_stop_intent(
&self,
workspace: &str,
request: &mut WorkerLifecycleRequest,
) -> ApiResult<()> {
let Some(intent) = request.stop_intent.as_mut() else {
return Ok(());
};
if intent.request_id.trim().is_empty()
|| intent.request_id.len() > 512
|| intent.request_id.chars().any(char::is_control)
{
return Err(Error::InvalidInput(
"Stop request_id must be nonempty bounded text".into(),
)
.into());
}
let source = match self {
Self::Browser { source, .. } => source.clone(),
Self::WorkerControl { controller } => protocol::AuthenticatedInputSource::Worker {
runtime_id: controller.runtime_id.clone(),
worker_id: controller.worker_id.clone(),
},
#[cfg(test)]
Self::Backend => return Ok(()),
};
let canonical = serde_json::to_vec(&(workspace, source.namespace(), &intent.request_id))
.expect("stop identity contains only strings");
let digest = Sha256::digest(canonical)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
intent.request_id = format!("runtime-stop:{digest}");
Ok(())
}
fn bind_input(&self, request: &mut WorkerInputRequest) {
// Public adapters cannot choose provenance or consume another input path's
// receipt IDs. Bind these only after the current caller is authorized.
@@ -443,42 +479,179 @@ impl WorkspaceWorker {
self.api.restore_workspace_worker(&self.identity, request)
}
pub(super) async fn restore_current_subject(
&self,
context: &WorkerOperationContext,
request: server_api::WorkerRestoreRequest,
subject: String,
expected: server_api::SubjectBodyRef,
) -> ApiResult<InternalWorkerRestoreResult> {
let guard = self.authorize_operation(context, "restore").await?;
let worker = self.clone();
self.api
.host_executors
.host()?
.run(move || {
let _control = guard;
worker.current_record()?;
worker.api.restore_workspace_worker_with_subject(
&worker.identity,
request,
Some((&subject, &expected)),
)
})
.await
}
pub(super) async fn stop_outcome_current_subject(
&self,
context: &WorkerOperationContext,
mut request: WorkerLifecycleRequest,
subject: String,
expected: server_api::SubjectBodyRef,
) -> ApiResult<runtime_api::WorkerStopOutcome> {
let control = self.authorize_operation(context, "stop").await?;
context.bind_stop_intent(self.api.workspace_id(), &mut request)?;
let worker = self.clone();
self.api
.host_executors
.host()?
.run(move || {
let _control = control;
{
let gate = subjektiv_journal::lifecycle_lock(&worker.api, &subject)?;
let _subject = gate
.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))?;
subject_body::require_expected(&worker.api, &subject, Some(&expected))?;
}
// Read-only receipt lookup may wait for an ongoing cleanup callback;
// never retain the Subject gate while waiting for the Worker lock.
worker
.api
.runtime
.worker_stop_outcome(&worker.identity, request)
.map_err(|error| error.into_error().into())
})
.await
}
pub(super) async fn stop_current_subject(
&self,
context: &WorkerOperationContext,
request: WorkerLifecycleRequest,
subject: String,
expected: server_api::SubjectBodyRef,
) -> ApiResult<WorkerLifecycleResult> {
self.stop_impl(context, request, Some((subject, expected)))
.await
}
pub(super) async fn stop(
&self,
context: &WorkerOperationContext,
request: WorkerLifecycleRequest,
) -> ApiResult<WorkerLifecycleResult> {
self.stop_impl(context, request, None).await
}
async fn stop_impl(
&self,
context: &WorkerOperationContext,
mut request: WorkerLifecycleRequest,
expected_subject: Option<(String, server_api::SubjectBodyRef)>,
) -> ApiResult<WorkerLifecycleResult> {
let guard = self.authorize_operation(context, "stop").await?;
context.bind_stop_intent(self.api.workspace_id(), &mut request)?;
let worker = self.clone();
// Once admitted, Runtime stop, session close and linked Workdir
// publication have one owner. Never hand an accepted stop's guards
// back to its cancellable waiter or acquire this Host lane again.
run_host_workflow(self.api.host_executors.host()?, async move {
let _control = guard;
let mut expected_subject = expected_subject;
if expected_subject.is_none() {
if let Some(lease) = worker
.api
.store
.retained_worker_singleton_lease(worker.api.workspace_id(), &worker.identity)?
{
if let Some(subject) = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) {
expected_subject =
Some((subject.to_owned(), subject_body::reference(&lease)));
}
}
}
if expected_subject.is_some() && request.stop_intent.is_none() {
return Err(Error::InvalidInput(
"Subject body stop requires request identity and its observed Runtime token"
.into(),
)
.into());
}
// Observe concrete resource lifetimes under the existing session
// fence, then release it before Runtime/cleanup can call back here.
let (observed_sessions, observed_links) = {
let _session = current_worker_session_lock(&worker.api, &worker.identity)
.lock_owned()
.await;
let links = worker
.api
.store
.list_worker_workdir_links(worker.api.workspace_id(), &worker.identity)?;
let sessions = worker
.api
.workdir_sessions
.lock()
.map_err(|_| Error::Store("Workdir session registry was poisoned".into()))?
.observe_worker(&worker.identity);
(sessions, links)
};
if let Some((subject, expected)) = expected_subject.as_ref() {
let gate = subjektiv_journal::lifecycle_lock(&worker.api, subject)?;
let _subject = gate
.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))?;
subject_body::require_expected(&worker.api, subject, Some(expected))?;
worker
.api
.runtime
.coordinate_worker_stop(&worker.identity, request.clone())
.map_err(RuntimeRegistryError::into_error)?;
}
// Admission owns the exact pending stop before releasing the Subject
// gate. Execution may call back into Session/Journal/Subject services.
let result = worker
.api
.runtime
.stop_worker(&worker.identity, request)
.map_err(|error| error.into_error())?;
if result.state != InternalWorkerOperationState::Accepted {
.map_err(RuntimeRegistryError::into_error)?;
if let Some(conflict) = result.diagnostics.iter().find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"stop_observation_conflict" | "stop_request_conflict"
)
}) {
return Err(Error::RepositoryConflict(conflict.message.clone()).into());
}
if result.state != InternalWorkerOperationState::Accepted || result.replayed {
return Ok(result);
}
parse_runtime_worker_id_for_registry(&worker.identity.worker_id)?;
let _session = current_worker_session_lock(&worker.api, &worker.identity)
.lock_owned()
.await;
close_current_worker_session_locked(&worker.api, &worker.identity).await?;
if let Some(record) = worker
.api
.store
.get_worker_registry(worker.api.workspace_id(), &worker.identity)?
{
sync_linked_workdir_after_worker_stop(
&worker.api,
&worker.identity.runtime_id,
&record,
)?;
}
close_observed_worker_workdir_sessions(
&worker.api.workdir_sessions,
&observed_sessions,
)
.await
.map_err(|message| Error::RuntimeOperationFailed {
runtime_id: worker.identity.runtime_id.clone(),
code: "workdir_session_close_failed".into(),
message,
})?;
sync_linked_workdir_after_worker_stop(&worker.api, &worker.identity, &observed_links)?;
Ok(result)
})
.await
@@ -489,6 +662,9 @@ impl WorkspaceWorker {
context: &WorkerOperationContext,
request: WorkerLifecycleRequest,
) -> ApiResult<WorkerLifecycleResult> {
if request.stop_intent.is_some() {
return Err(Error::InvalidInput("stop_intent is not valid for Cancel".into()).into());
}
let guard = self.authorize_operation(context, "cancel").await?;
let worker = self.clone();
self.api
@@ -890,6 +1066,58 @@ mod input_binding_tests {
}
}
#[test]
fn stop_identity_is_bound_to_authenticated_account_and_workspace_without_replacing_the_observation()
{
let context = |account: &str| WorkerOperationContext::Browser {
headers: HeaderMap::new(),
source: protocol::AuthenticatedInputSource::Account {
account_id: account.into(),
},
};
let request = || WorkerLifecycleRequest {
stop_intent: Some(runtime_api::WorkerStopIntent {
request_id: "same-request".into(),
expected_observation_token: "observed-lifecycle".into(),
}),
..Default::default()
};
let mut first = request();
context("account-a")
.bind_stop_intent("workspace-a", &mut first)
.unwrap();
let mut retry = request();
context("account-a")
.bind_stop_intent("workspace-a", &mut retry)
.unwrap();
assert_eq!(first, retry);
assert_eq!(
first
.stop_intent
.as_ref()
.unwrap()
.expected_observation_token,
"observed-lifecycle"
);
for (account, workspace) in [("account-b", "workspace-a"), ("account-a", "workspace-b")] {
let mut other = request();
context(account)
.bind_stop_intent(workspace, &mut other)
.unwrap();
assert_ne!(
first.stop_intent.as_ref().unwrap().request_id,
other.stop_intent.as_ref().unwrap().request_id
);
}
let mut empty = request();
empty.stop_intent.as_mut().unwrap().request_id.clear();
assert!(
context("account-a")
.bind_stop_intent("workspace-a", &mut empty)
.is_err()
);
}
#[test]
fn generic_input_binds_caller_source_and_isolates_client_ids_from_chat_and_other_paths() {
let browser = |id: &str| WorkerOperationContext::Browser {
@@ -376,6 +376,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -479,6 +480,7 @@ async fn orchestrator_attention_restore_keeps_pending_unknown_delivery_and_id()
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -1023,6 +1025,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -1131,6 +1134,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -1151,6 +1155,7 @@ async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledg
.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -138,6 +138,7 @@ fn delivery_stop(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
.stop_worker(
worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -443,6 +443,7 @@ mod plugin_controller_tests {
api.runtime.stop_worker(
&worker,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -336,6 +336,7 @@ mod plugin_remote_controller_tests {
api.runtime.stop_worker(
&stopped,
WorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
},
@@ -658,6 +658,7 @@ async fn worker_control_source_lookup_waits_off_tokio_and_retains_finite_host_ow
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let request = || {
Json(server_api::RuntimeWorkerLifecycleRequest {
stop_intent: None,
reason: None,
ticket_assignment: None,
})
+14
View File
@@ -3668,6 +3668,20 @@ impl SqliteWorkspaceStore {
})
}
pub(crate) fn reserved_worker_create_runtime(
&self,
workspace_id: &str,
allocation_key: &str,
) -> Result<Option<String>> {
self.with_conn(|conn| {
Ok(conn.query_row(
"SELECT runtime_id FROM worker_create_reservations WHERE workspace_id = ?1 AND allocation_key = ?2",
params![workspace_id, allocation_key],
|row| row.get(0),
).optional()?)
})
}
pub(crate) fn current_worker_singleton_owner(
&self,
workspace_id: &str,
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
@@ -319,8 +319,8 @@ export function parseRuntimeWorkerLifecycleResult(
const item = object(value, "Runtime Worker lifecycle result");
exactKeys(
item,
["state", "runtime_id", "worker_id", "diagnostics"],
[],
["state", "runtime_id", "worker_id", "diagnostics", "replayed"],
["replayed"],
"Runtime Worker lifecycle result",
);
const state = boundedString(
@@ -331,6 +331,9 @@ export function parseRuntimeWorkerLifecycleResult(
fail("Runtime Worker lifecycle result.state", "has an unknown value");
}
return {
replayed: item.replayed === undefined
? false
: boolean(item.replayed, "Runtime Worker lifecycle result.replayed"),
state: state as WorkerOperationState,
runtime_id: boundedString(
item.runtime_id,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { SubjectBodyMutationResponse, SubjectBodyResponse, SubjectBodyStopOutcomeResponse } from '#lib/generated/subject-body-api.ts';
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
import {
clearSubjectBodyAttempt, createSubjectBodyAttempt, getSubjectBody, getSubjectBodyStopOutcome,
readSubjectBodyAttempt, saveSubjectBodyAttempt, startSubjectBody,
stopSubjectBody, subjectBodyControls, SubjectBodyApiError, type SubjectBodyAttempt,
} from './subject-body.ts';
let { accountId, workspaceId, subjectId }: { accountId: string; workspaceId: string; subjectId: string } = $props();
const headingId = $props.id();
let body = $state.raw<SubjectBodyResponse | null>(null);
let attempt = $state.raw<SubjectBodyAttempt | null>(null);
let receipt = $state.raw<SubjectBodyMutationResponse | null>(null);
let stopOutcome = $state.raw<SubjectBodyStopOutcomeResponse | null>(null);
let outcomeError = $state<string | null>(null);
let checkingOutcome = $state(false);
let loadError = $state<string | null>(null);
let operationError = $state<string | null>(null);
let storageError = $state<string | null>(null);
let loading = $state(false);
let sending = $state(false);
let fresh = $state(false);
let activeRoute = $state('');
let routeGeneration = 0;
let observationRequest = 0;
const identityKey = (account: string, workspace: string, subject: string) => JSON.stringify([account, workspace, subject]);
let hasAccount = $derived(typeof accountId === 'string' && accountId.length > 0);
let visible = $derived(hasAccount && activeRoute === identityKey(accountId, workspaceId, subjectId));
let snapshot = $derived(visible ? body : null);
let controls = $derived(snapshot ? subjectBodyControls(snapshot) : { start: false, stop: false });
let retryAuthorized = $derived(snapshot && attempt && (attempt.action === 'start' ? snapshot.can_start : snapshot.can_stop));
let worker = $derived(snapshot?.observation.kind === 'available' ? snapshot.observation.worker : null);
let workerDiagnostics = $derived(worker?.diagnostics ?? []);
function current(generation: number, account: string, workspace: string, subject: string): boolean {
return routeGeneration === generation && accountId === account && workspaceId === workspace && subjectId === subject;
}
$effect(() => {
const account = accountId;
const workspace = workspaceId;
const subject = subjectId;
untrack(() => {
routeGeneration += 1;
observationRequest += 1;
activeRoute = identityKey(account, workspace, subject);
body = null;
attempt = null;
receipt = null;
stopOutcome = null;
outcomeError = null;
checkingOutcome = false;
loadError = null;
operationError = null;
storageError = null;
sending = false;
loading = false;
fresh = false;
if (typeof account !== 'string' || account.length === 0) return;
try { attempt = readSubjectBodyAttempt(sessionStorage, account, workspace, subject); }
catch { storageError = 'The saved body request cannot be read. Do not create a replacement request. Restore access to this tab’s session storage and reload.'; }
void refresh();
});
return () => { routeGeneration += 1; observationRequest += 1; };
});
async function refresh(): Promise<void> {
if (!visible) return;
const generation = routeGeneration;
const account = accountId;
const workspace = workspaceId;
const subject = subjectId;
const request = ++observationRequest;
const latest = () => current(generation, account, workspace, subject) && request === observationRequest;
loading = true;
fresh = false;
loadError = null;
try {
const next = await getSubjectBody(fetch, workspace, subject);
if (!latest()) return;
body = next;
fresh = true;
} catch (cause) {
if (!latest()) return;
loadError = (cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Body state could not be confirmed. Check your connection and viewing permissions, then refresh.';
} finally {
if (latest()) loading = false;
}
}
async function submit(action?: 'start' | 'stop'): Promise<void> {
if (!visible || sending || checkingOutcome || loading || storageError || !fresh || !snapshot) return;
if (attempt ? !retryAuthorized : (!action || !controls[action])) return;
const generation = routeGeneration;
const account = accountId;
const workspace = workspaceId;
const subject = subjectId;
let frozen: SubjectBodyAttempt;
try {
frozen = attempt ?? createSubjectBodyAttempt(account, snapshot, action!, crypto.randomUUID());
// 送信より先に全入力を保存する。保存不能・既存の別依頼は送らずに止める。
saveSubjectBodyAttempt(sessionStorage, account, frozen);
} catch {
storageError = 'The exact request could not be retained in this tab. No request was sent. Restore session storage access and reload before trying again.';
return;
}
attempt = frozen;
sending = true;
operationError = null;
receipt = null;
stopOutcome = null;
outcomeError = null;
fresh = false;
const observation = ++observationRequest;
try {
const result = frozen.action === 'start'
? await startSubjectBody(fetch, workspace, subject, frozen.request)
: await stopSubjectBody(fetch, workspace, subject, frozen.request);
if (!current(generation, account, workspace, subject)) return;
receipt = result;
// receiptのacceptedは実行状態ではない。bodyがあれば独立した観測として扱う。
if (result.body && observation === observationRequest) body = result.body;
if (result.disposition !== 'unknown') {
try {
clearSubjectBodyAttempt(sessionStorage, account, frozen);
attempt = null;
} catch {
storageError = 'The request receipt was received, but its saved retry input could not be cleared. Reload to reconcile the retained request; do not create another request.';
}
}
} catch (cause) {
if (!current(generation, account, workspace, subject)) return;
operationError = (cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Request outcome unknown. The request may already have taken effect. Its exact input is retained; refresh observations and explicitly retry the same request to reconcile.';
} finally {
if (current(generation, account, workspace, subject)) {
sending = false;
// GET更新だけではunknownの再送入力を消さない。
void refresh();
}
}
}
async function checkStopOutcome(): Promise<void> {
if (!visible || sending || checkingOutcome || storageError || !snapshot?.can_stop || attempt?.action !== 'stop' || attempt.accountId !== accountId) return;
const generation = routeGeneration;
const account = accountId;
const workspace = workspaceId;
const subject = subjectId;
const frozen = attempt;
checkingOutcome = true;
outcomeError = null;
stopOutcome = null;
try {
const result = await getSubjectBodyStopOutcome(fetch, workspace, subject, frozen.request);
if (!current(generation, account, workspace, subject)) return;
stopOutcome = result;
// 停止照会のbodyは表示状態へ転記しない。現在の本体は独立したGETで更新する。
if (result.outcome === 'stopped') {
receipt = null;
operationError = null;
try {
clearSubjectBodyAttempt(sessionStorage, account, frozen);
attempt = null;
} catch {
storageError = 'The historical stop was confirmed, but its saved retry input could not be cleared. Reload to reconcile the retained request; do not create another request.';
}
}
// not_found/pendingとGET更新のいずれも、凍結した停止依頼を消さない。
void refresh();
} catch (cause) {
if (!current(generation, account, workspace, subject)) return;
outcomeError = (cause instanceof SubjectBodyApiError ? `${cause.message}. ` : '') + 'Stop outcome could not be checked. This is not a missing receipt or a confirmed stop. The exact request is retained; try the read-only check again.';
} finally {
if (current(generation, account, workspace, subject)) checkingOutcome = false;
}
}
</script>
<section class="subject-body" aria-labelledby={headingId}>
<header>
<h2 id={headingId}>Conversation body</h2>
{#if hasAccount}<button type="button" class="workspace-secondary-button" disabled={!visible || loading} onclick={() => { void refresh(); }}>Refresh body state</button>{/if}
</header>
{#if !hasAccount}
<p role="status">Sign in to view this conversation body.</p>
{:else if !visible || (!snapshot && loading)}
<p role="status">Loading body state…</p>
{:else if snapshot}
{#if !fresh}<p class="muted">{loading ? 'Updating body state…' : 'Last confirmed observation; refresh before acting.'}</p>{/if}
{#if snapshot.observation.kind === 'absent'}
<p>No current conversation body.</p>
{:else if snapshot.observation.kind === 'unavailable'}
<p><strong>Body owner present; observation unavailable.</strong> This is not an absent or stopped body. A replacement will not be started.</p>
{#each snapshot.observation.diagnostics as diagnostic}
<p class="workspace-callout" role="status">{diagnostic.message}</p>
{/each}
{:else if worker}
{#if worker.availability !== 'observed'}
<p><strong>Body owner present; Worker observation unavailable.</strong> Running or stopped state is not confirmed.</p>
{:else if liveWorkerState(worker) === 'stopped'}
<p><strong>Body stopped.</strong> Start restores this body; retained conversation and Memory remain available.</p>
{:else if worker.worker_state}
<dl><div><dt>Worker lifecycle</dt><dd>{worker.state}</dd></div><div><dt>Live execution</dt><dd>{liveWorkerState(worker)}</dd></div></dl>
{:else}
<p><strong>Body owner present; live execution not confirmed.</strong> Refresh before acting.</p>
{/if}
{#if worker.availability === 'observed' && !worker.restore_observation_token}
<p class="muted">Lifecycle controls are unavailable until the current body’s observation token can be confirmed. Refresh body state before acting.</p>
{/if}
{#each workerDiagnostics as diagnostic}
<p class="workspace-callout" role="status">{diagnostic.message}</p>
{/each}
{/if}
{#if !snapshot.can_start || !snapshot.can_stop}
<p class="muted">{!snapshot.can_start && !snapshot.can_stop ? 'You do not have permission to start or stop this body.' : !snapshot.can_start ? 'You do not have permission to start this body.' : 'You do not have permission to stop this body.'} Ask a Workspace administrator to review access; this control does not add permissions.</p>
{/if}
{#if !attempt}
<div class="actions">
{#if controls.start}
<button type="button" class="workspace-secondary-button" disabled={!fresh || loading || sending || !!storageError} onclick={() => { void submit('start'); }}>{snapshot.current ? 'Restart conversation body' : 'Start conversation body'}</button>
{/if}
{#if controls.stop}
<button type="button" class="workspace-secondary-button" disabled={!fresh || loading || sending || !!storageError} onclick={() => { void submit('stop'); }}>Stop conversation body</button>
{/if}
</div>
{#if controls.stop}<p class="muted">Stopping ends the body Worker, not the Subject. Conversation and Memory are retained.</p>{/if}
{/if}
{/if}
{#if visible}
{#if loadError}<p class="workspace-callout is-error" role="alert">{loadError}</p>{/if}
{#if storageError}<p class="workspace-callout is-error" role="alert">{storageError}</p>{/if}
{#if sending}<p role="status">Sending request; body state is not yet confirmed.</p>{/if}
{#if receipt}
<div role="status">
{#if receipt.disposition === 'accepted'}
<p><strong>Request accepted.</strong> Acceptance is not confirmation of running or stopped state. Check the body observation above.</p>
{:else if receipt.disposition === 'rejected'}
<p><strong>Request rejected.</strong> Review the diagnostics and refresh before a new action. Check Subject permissions, the default profile and model configuration; no permissions were added.</p>
{:else}
<p><strong>Request outcome unknown.</strong> {attempt?.action === 'stop' ? 'Check the saved stop outcome without sending another stop, or explicitly retry the same saved request.' : 'Refresh observations, then retry the same saved request to reconcile.'} Refresh alone does not settle this receipt.</p>
{/if}
{#each receipt.diagnostics as diagnostic}<p class="workspace-callout">{diagnostic.message}</p>{/each}
</div>
{/if}
{#if checkingOutcome}<p role="status">Checking saved stop outcome… No stop request is sent.</p>{/if}
{#if stopOutcome}
<div role="status">
{#if stopOutcome.outcome === 'stopped'}
<p><strong>Historical stop confirmed.</strong> This settles the saved stop operation, not the current body’s state. The body may have been restored since then; see the current observation above.</p>
{:else if stopOutcome.outcome === 'pending'}
<p><strong>Stop outcome pending.</strong> Stopping is not confirmed. The complete saved request is retained; check again or explicitly retry the same request.</p>
{:else}
<p><strong>No stop receipt found.</strong> This does not prove that stopping failed or that the current body is stopped. The complete saved request is retained; check again or explicitly retry the same request.</p>
{/if}
</div>
{/if}
{#if outcomeError}<p class="workspace-callout is-error" role="alert">{outcomeError}</p>{/if}
{#if operationError}<p class="workspace-callout is-error" role="alert">{operationError}</p>{/if}
{#if attempt && !sending}
{#if !receipt && !operationError && stopOutcome?.outcome !== 'stopped'}<p role="status">An unresolved {attempt.action} request is retained in this tab. Refresh and retry the same request; do not create a replacement.</p>{/if}
{#if attempt.action === 'stop' && snapshot?.can_stop}
<button type="button" class="workspace-secondary-button" disabled={checkingOutcome || !!storageError} onclick={() => { void checkStopOutcome(); }}>Check stop outcome</button>
{/if}
{#if retryAuthorized}
<button type="button" class="workspace-secondary-button" disabled={!fresh || loading || checkingOutcome || !!storageError} onclick={() => { void submit(); }}>Retry same {attempt.action} request</button>
{/if}
<details><summary>Request diagnostics</summary><p>Request ID: <code>{attempt.request.request_id}</code></p><p>The complete frozen request is retained across reloads in this tab. Retrying does not use a new request ID or a newer observation token.</p></details>
{/if}
{/if}
</section>
<style>
.subject-body { min-width: 0; overflow-wrap: anywhere; }
header, .actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
header { justify-content: space-between; }
h2 { margin: 0; font-size: var(--font-size-body); line-height: var(--line-height-body); }
p, dl, details { margin-block: var(--space-3); }
.muted, dt { color: var(--text-muted); }
dl > div { display: flex; flex-wrap: wrap; gap: var(--space-3); }
dd { margin: 0; }
code { font-family: var(--font-mono); }
summary { cursor: pointer; }
button { font-size: var(--font-size-body); line-height: var(--line-height-body); border-radius: var(--radius-soft); }
button:hover:not(:disabled) { background: var(--interactive-hover); }
button:active:not(:disabled) { background: var(--interactive-selected); }
button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
+30 -1
View File
@@ -607,9 +607,11 @@ export function parseSubjektivSubjectResponse(
"created_at",
"updated_at",
"current_worker",
"current_body",
"current_worker_diagnostics",
],
"Subject response",
["current_worker"],
["current_worker", "current_body", "current_worker_diagnostics"],
);
const id = requiredIdentifier(record, "id");
if (expectedSubjectId !== undefined && id !== expectedSubjectId) {
@@ -638,6 +640,33 @@ export function parseSubjektivSubjectResponse(
"Subject current worker",
);
}
if ("current_body" in record) {
if (record.current_body === null) result.current_body = null;
else {
const body = strictRecord(record.current_body, [
"runtime_id",
"worker_id",
"generation",
], "Subject body owner");
const generation = requiredString(body, "generation", 20);
if (
!/^[1-9][0-9]{0,19}$/.test(generation) ||
BigInt(generation) > 18446744073709551615n
) invalid("invalid Subject body generation");
result.current_body = {
runtime_id: requiredIdentifier(body, "runtime_id"),
worker_id: requiredIdentifier(body, "worker_id"),
generation,
};
}
}
if ("current_worker_diagnostics" in record) {
result.current_worker_diagnostics = boundedArray(
record.current_worker_diagnostics,
100,
"current worker diagnostics",
).map(parseDiagnostic);
}
return result;
}
@@ -0,0 +1,721 @@
import {
clearSubjectBodyAttempt,
createSubjectBodyAttempt,
getSubjectBody,
getSubjectBodyStopOutcome,
parseSubjectBodyMutationResponse,
parseSubjectBodyResponse,
parseSubjectBodyStopOutcomeResponse,
readSubjectBodyAttempt,
saveSubjectBodyAttempt,
startSubjectBody,
stopSubjectBody,
subjectBodyControls,
subjectBodyStorageKey,
} from "./subject-body.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assert(value: unknown): asserts value {
if (!value) throw new Error("Assertion failed");
}
function rejects(fn: () => unknown): void {
let rejected = false;
try {
fn();
} catch {
rejected = true;
}
assert(rejected);
}
const owner = {
runtime_id: "runtime",
worker_id: "worker",
generation: "18446744073709551615",
};
function absent() {
return {
workspace_id: "workspace",
subject_id: "subject",
current: null,
observation: { kind: "absent" },
can_start: true,
can_stop: true,
};
}
function available(state = "stopped") {
return {
...absent(),
current: owner,
observation: {
kind: "available",
worker: {
runtime_id: owner.runtime_id,
worker_id: owner.worker_id,
resource_key: "W-1",
host_id: "host",
label: "Body",
workspace: {
visibility: "workspace",
identity: "workspace",
workspace_id: "workspace",
},
implementation: { kind: "embedded", display_hint: "" },
availability: "observed",
state,
worker_state: state === "stopped"
? null
: { last_command_id: 1, state: { kind: "idle" } },
restore_observation_token: "observation-1",
},
},
};
}
function parse(value: unknown) {
return parseSubjectBodyResponse(value, "workspace", "subject");
}
function storage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
};
}
Deno.test("absent permits initial start while unavailable retains owner and forbids replacement", () => {
assert(subjectBodyControls(parse(absent())).start);
const unavailable = parse({
...absent(),
current: owner,
observation: {
kind: "unavailable",
diagnostics: [{
code: "runtime_unavailable",
severity: "warning",
message: "Cannot observe Runtime",
}],
},
});
assert(unavailable.current?.generation === "18446744073709551615");
assert(
!subjectBodyControls(unavailable).start &&
!subjectBodyControls(unavailable).stop,
);
rejects(() => parse({ ...absent(), current: owner }));
rejects(() =>
parse({
...absent(),
observation: { kind: "unavailable", diagnostics: [] },
})
);
});
Deno.test("ordinary Worker parsing keeps availability and live execution distinct from lifecycle", () => {
const stopped = parse(available());
assert(
subjectBodyControls(stopped).start && !subjectBodyControls(stopped).stop,
);
const idle = parse(available("idle"));
assert(subjectBodyControls(idle).stop && !subjectBodyControls(idle).start);
const unobserved = available("idle");
unobserved.observation.worker.availability = "unavailable";
unobserved.observation.worker.worker_state = null;
assert(
!subjectBodyControls(parse(unobserved)).start &&
!subjectBodyControls(parse(unobserved)).stop,
);
const invalid = available();
invalid.observation.worker.availability = "invented";
rejects(() => parse(invalid));
const noPermission = parse({
...available("idle"),
can_start: false,
can_stop: false,
});
assert(
!subjectBodyControls(noPermission).start &&
!subjectBodyControls(noPermission).stop,
);
});
Deno.test("body responses reject cross-route identities and mismatched owner Worker", () => {
rejects(() => parse({ ...absent(), workspace_id: "other" }));
rejects(() => parse({ ...absent(), subject_id: "other" }));
const mismatch = available();
mismatch.observation.worker.worker_id = "old-worker";
rejects(() => parse(mismatch));
const foreignWorkspace = available();
foreignWorkspace.observation.worker.workspace.workspace_id = "other";
rejects(() => parse(foreignWorkspace));
});
Deno.test("external body JSON requires nullable current and bounds generations diagnostics and unknown fields", () => {
const missing: Record<string, unknown> = absent();
delete missing.current;
rejects(() => parse(missing));
for (
const generation of [1, "1e3", "-1", "18446744073709551616", "1".repeat(30)]
) {
rejects(() => parse({ ...available(), current: { ...owner, generation } }));
}
rejects(() => parse({ ...absent(), can_start: "true" }));
rejects(() => parse({ ...absent(), extra: true }));
rejects(() =>
parse({
...available(),
observation: {
kind: "unavailable",
diagnostics: Array(129).fill({
code: "x",
severity: "warning",
message: "x",
}),
},
})
);
});
Deno.test("mutation receipts validate request identity and nested route without deriving execution state", () => {
const receipt = {
request_id: "request",
disposition: "accepted",
diagnostics: [],
body: absent(),
};
const parsed = parseSubjectBodyMutationResponse(
receipt,
"workspace",
"subject",
"request",
);
assert(
parsed.disposition === "accepted" &&
parsed.body?.observation.kind === "absent",
);
rejects(() =>
parseSubjectBodyMutationResponse(
receipt,
"workspace",
"subject",
"different",
)
);
rejects(() =>
parseSubjectBodyMutationResponse(receipt, "other", "subject", "request")
);
rejects(() =>
parseSubjectBodyMutationResponse(
{ ...receipt, disposition: "running" },
"workspace",
"subject",
"request",
)
);
});
Deno.test("durable retry input is frozen complete account-and-route-scoped and never silently replaced", () => {
const saved = storage();
const attempt = createSubjectBodyAttempt(
"account",
parse(available()),
"start",
"stable-request",
);
saveSubjectBodyAttempt(saved, "account", attempt);
const restored = readSubjectBodyAttempt(
saved,
"account",
"workspace",
"subject",
);
assert(restored && JSON.stringify(restored) === JSON.stringify(attempt));
assert(
Object.isFrozen(restored) && Object.isFrozen(restored.request) &&
Object.isFrozen(restored.request.expected_current),
);
assert(restored.request.expected_observation_token === "observation-1");
assert(
readSubjectBodyAttempt(saved, "account", "workspace", "other") === null,
);
const newer = createSubjectBodyAttempt(
"account",
parse(available()),
"start",
"new-request",
);
rejects(() => saveSubjectBodyAttempt(saved, "account", newer));
rejects(() => clearSubjectBodyAttempt(saved, "account", newer));
saveSubjectBodyAttempt(saved, "account", restored);
clearSubjectBodyAttempt(saved, "account", restored);
assert(
readSubjectBodyAttempt(saved, "account", "workspace", "subject") === null,
);
});
Deno.test("unreadable or incomplete saved request blocks replacement rather than generating identity", () => {
const saved = storage();
const key = subjectBodyStorageKey("account", "workspace", "subject");
for (
const raw of [
"not-json",
"x".repeat(17000),
JSON.stringify({
accountId: "account",
workspaceId: "workspace",
subjectId: "subject",
action: "start",
request: { request_id: "only-id" },
}),
JSON.stringify({
accountId: "account",
workspaceId: "workspace",
subjectId: "other",
action: "start",
request: {
request_id: "request",
expected_current: null,
expected_observation_token: null,
},
}),
]
) {
saved.setItem(key, raw);
rejects(() =>
readSubjectBodyAttempt(saved, "account", "workspace", "subject")
);
rejects(() =>
saveSubjectBodyAttempt(
saved,
"account",
createSubjectBodyAttempt(
"account",
parse(absent()),
"start",
"replacement",
),
)
);
assert(saved.getItem(key) === raw);
}
});
Deno.test("shared HTTP functions encode route segments and preserve exact start stop inputs", async () => {
const workspace = "space /";
const subject = "subject /";
const requests: { path: string; init?: RequestInit }[] = [];
const body = { ...absent(), workspace_id: workspace, subject_id: subject };
const fetchFn = ((path: string, init?: RequestInit) => {
requests.push({ path, init });
return Promise.resolve(Response.json(
init?.method === "POST"
? {
request_id: JSON.parse(init.body as string).request_id,
disposition: "unknown",
diagnostics: [],
body,
}
: body,
));
}) as typeof fetch;
const start = {
request_id: "same-start",
expected_current: null,
expected_observation_token: null,
};
const stop = {
request_id: "same-stop",
expected_current: owner,
expected_observation_token: "frozen-token",
};
await getSubjectBody(fetchFn, workspace, subject);
const result = await startSubjectBody(fetchFn, workspace, subject, start);
assert(result.disposition === "unknown");
await startSubjectBody(fetchFn, workspace, subject, start);
await stopSubjectBody(fetchFn, workspace, subject, stop);
assert(
requests[0].path ===
"/api/w/space%20%2F/subjektiv/subjects/subject%20%2F/body",
);
assert(requests[1].path === `${requests[0].path}/start`);
assert(requests[3].path === `${requests[0].path}/stop`);
assert(requests[1].init?.body === JSON.stringify(start));
assert(requests[2].init?.body === requests[1].init?.body);
assert(requests[3].init?.body === JSON.stringify(stop));
});
Deno.test("stop outcome validates historical receipt identity independently of current body", () => {
for (const outcome of ["not_found", "pending", "stopped"]) {
const result = parseSubjectBodyStopOutcomeResponse(
{
request_id: "saved-stop",
outcome,
body: available("running"),
},
"workspace",
"subject",
"saved-stop",
);
assert(result.outcome === outcome);
assert(
result.body?.observation.kind === "available" &&
result.body.observation.worker.state === "running",
);
}
const receipt = {
request_id: "saved-stop",
outcome: "stopped",
body: absent(),
};
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
receipt,
"workspace",
"subject",
"other-request",
)
);
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
receipt,
"other-workspace",
"subject",
"saved-stop",
)
);
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
receipt,
"workspace",
"other-subject",
"saved-stop",
)
);
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
{ ...receipt, outcome: "unknown" },
"workspace",
"subject",
"saved-stop",
)
);
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
{ ...receipt, body: undefined },
"workspace",
"subject",
"saved-stop",
)
);
rejects(() =>
parseSubjectBodyStopOutcomeResponse(
{ ...receipt, unexpected: true },
"workspace",
"subject",
"saved-stop",
)
);
});
Deno.test("stop outcome query sends only the read-only endpoint with full frozen input unchanged", async () => {
const saved = storage();
const attempt = createSubjectBodyAttempt(
"account",
parse(available("idle")),
"stop",
"saved-stop",
);
assert(attempt.action === "stop");
saveSubjectBodyAttempt(saved, "account", attempt);
const requests: { path: string; init?: RequestInit }[] = [];
const fetchFn = ((path: string, init?: RequestInit) => {
requests.push({ path, init });
return Promise.resolve(
Response.json({
request_id: "saved-stop",
outcome: "pending",
body: null,
}),
);
}) as typeof fetch;
const result = await getSubjectBodyStopOutcome(
fetchFn,
"workspace",
"subject",
attempt.request,
);
assert(result.outcome === "pending");
assert(requests.length === 1);
assert(
requests[0].path ===
"/api/w/workspace/subjektiv/subjects/subject/body/stop/outcome",
);
assert(requests[0].init?.method === "POST");
assert(requests[0].init?.body === JSON.stringify(attempt.request));
assert(
JSON.stringify(
readSubjectBodyAttempt(saved, "account", "workspace", "subject"),
) === JSON.stringify(attempt),
);
});
Deno.test("stop outcome authorization transport lookup and malformed receipt failures never become not_found", async () => {
const request = {
request_id: "saved-stop",
expected_current: owner,
expected_observation_token: "saved-token",
};
for (
const failure of [
"forbidden",
"lookup",
"http-not-found",
"transport",
"wrong-receipt",
"invalid-json",
]
) {
const fetchFn = (async () => {
if (failure === "transport") throw new Error("connection lost");
if (
failure === "forbidden" || failure === "lookup" ||
failure === "http-not-found"
) {
return Response.json({ error: "unavailable" }, {
status: failure === "forbidden"
? 403
: failure === "http-not-found"
? 404
: 502,
});
}
if (failure === "invalid-json") return new Response("invalid-json");
return Response.json({
request_id: "other-request",
outcome: "not_found",
body: null,
});
}) as typeof fetch;
let rejected = false;
try {
await getSubjectBodyStopOutcome(fetchFn, "workspace", "subject", request);
} catch {
rejected = true;
}
assert(rejected);
}
});
Deno.test("request IDs enforce 128 UTF-8 bytes and control-free input before sending or retaining", async () => {
for (const requestId of ["x".repeat(128), "é".repeat(64)]) {
const attempt = createSubjectBodyAttempt(
"account",
parse(absent()),
"start",
requestId,
);
assert(attempt.request.request_id === requestId);
}
for (
const requestId of [
"",
"x".repeat(129),
"é".repeat(65),
"request\n",
"request\u007f",
"request\u0085",
]
) {
rejects(() =>
createSubjectBodyAttempt("account", parse(absent()), "start", requestId)
);
const saved = storage();
saved.setItem(
subjectBodyStorageKey("account", "workspace", "subject"),
JSON.stringify({
accountId: "account",
workspaceId: "workspace",
subjectId: "subject",
action: "start",
request: {
request_id: requestId,
expected_current: null,
expected_observation_token: null,
},
}),
);
rejects(() =>
readSubjectBodyAttempt(saved, "account", "workspace", "subject")
);
let calls = 0;
const fetchFn = (() => {
calls += 1;
return Promise.resolve(Response.json({}));
}) as typeof fetch;
const stop = {
request_id: requestId,
expected_current: owner,
expected_observation_token: "saved-token",
};
const start = {
request_id: requestId,
expected_current: null,
expected_observation_token: null,
};
for (
const invoke of [
() => startSubjectBody(fetchFn, "workspace", "subject", start),
() => stopSubjectBody(fetchFn, "workspace", "subject", stop),
() => getSubjectBodyStopOutcome(fetchFn, "workspace", "subject", stop),
]
) {
let rejected = false;
try {
await invoke();
} catch {
rejected = true;
}
assert(rejected);
}
assert(calls === 0);
}
});
Deno.test("durable requests are account-scoped and another account cannot read save or clear them", () => {
const saved = storage();
const first = createSubjectBodyAttempt(
"account-a",
parse(available()),
"start",
"first-request",
);
saveSubjectBodyAttempt(saved, "account-a", first);
const firstKey = subjectBodyStorageKey("account-a", "workspace", "subject");
const secondKey = subjectBodyStorageKey("account-b", "workspace", "subject");
assert(
firstKey ===
`yoi.subject-body:${
JSON.stringify(["account-a", "workspace", "subject"])
}`,
);
assert(firstKey !== secondKey);
const firstRaw = saved.getItem(firstKey);
const accesses: string[] = [];
const observed = {
getItem(key: string) {
accesses.push(`get:${key}`);
return saved.getItem(key);
},
setItem(key: string, value: string) {
accesses.push(`set:${key}`);
saved.setItem(key, value);
},
removeItem(key: string) {
accesses.push(`remove:${key}`);
saved.removeItem(key);
},
};
assert(
readSubjectBodyAttempt(observed, "account-b", "workspace", "subject") ===
null,
);
assert(accesses.length === 1 && accesses[0] === `get:${secondKey}`);
accesses.length = 0;
rejects(() => saveSubjectBodyAttempt(observed, "account-b", first));
rejects(() => clearSubjectBodyAttempt(observed, "account-b", first));
assert(accesses.length === 0);
const second = createSubjectBodyAttempt(
"account-b",
parse(absent()),
"start",
"second-request",
);
saveSubjectBodyAttempt(saved, "account-b", second);
clearSubjectBodyAttempt(saved, "account-b", second);
assert(saved.getItem(firstKey) === firstRaw);
const restored = readSubjectBodyAttempt(
saved,
"account-a",
"workspace",
"subject",
);
assert(restored?.accountId === "account-a");
assert(JSON.stringify(restored) === JSON.stringify(first));
assert(
Object.isFrozen(restored) && Object.isFrozen(restored.request) &&
Object.isFrozen(restored.request.expected_current),
);
});
Deno.test("a foreign or accountless attempt copied into the current account key is rejected unchanged", () => {
const saved = storage();
const first = createSubjectBodyAttempt(
"account-a",
parse(available("idle")),
"stop",
"first-stop",
);
const secondKey = subjectBodyStorageKey("account-b", "workspace", "subject");
const accountless = {
workspaceId: first.workspaceId,
subjectId: first.subjectId,
action: first.action,
request: first.request,
};
for (const raw of [JSON.stringify(first), JSON.stringify(accountless)]) {
saved.setItem(secondKey, raw);
rejects(() =>
readSubjectBodyAttempt(saved, "account-b", "workspace", "subject")
);
const replacement = createSubjectBodyAttempt(
"account-b",
parse(absent()),
"start",
"new-request",
);
rejects(() => saveSubjectBodyAttempt(saved, "account-b", replacement));
assert(saved.getItem(secondKey) === raw);
}
});
Deno.test("accountless legacy keys are neither discovered migrated nor cleared for a signed-in account", () => {
const saved = storage();
const legacyKey = `yoi.subject-body:${
JSON.stringify(["workspace", "subject"])
}`;
saved.setItem(legacyKey, "unattributed-request");
const accessed: string[] = [];
const observed = {
getItem(key: string) {
accessed.push(key);
return saved.getItem(key);
},
};
assert(
readSubjectBodyAttempt(observed, "account-a", "workspace", "subject") ===
null,
);
assert(
accessed.length === 1 &&
accessed[0] ===
subjectBodyStorageKey("account-a", "workspace", "subject"),
);
const own = createSubjectBodyAttempt(
"account-a",
parse(absent()),
"start",
"own-request",
);
saveSubjectBodyAttempt(saved, "account-a", own);
clearSubjectBodyAttempt(saved, "account-a", own);
assert(saved.getItem(legacyKey) === "unattributed-request");
rejects(() => subjectBodyStorageKey("", "workspace", "subject"));
rejects(() =>
createSubjectBodyAttempt("", parse(absent()), "start", "no-account")
);
});
@@ -0,0 +1,551 @@
import type {
SubjectBodyMutationResponse,
SubjectBodyObservation,
SubjectBodyRef,
SubjectBodyResponse,
SubjectBodyStartRequest,
SubjectBodyStopOutcomeResponse,
SubjectBodyStopRequest,
} from "#lib/generated/subject-body-api.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
type Diagnostic = SubjectBodyMutationResponse["diagnostics"][number];
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_SAVED_LENGTH = 16 * 1024;
function record(value: unknown, keys: string[]): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Subject body value must be an object");
}
if (Object.keys(value).some((key) => !keys.includes(key))) {
throw new Error("Subject body value contains unknown fields");
}
return value as Record<string, unknown>;
}
function text(value: unknown, max = 4096): string {
if (typeof value !== "string" || value.length === 0 || value.length > max) {
throw new Error("Subject body string is invalid");
}
return value;
}
function parseRequestId(value: unknown): string {
const result = text(value, 128);
if (
new TextEncoder().encode(result).byteLength > 128 || /\p{Cc}/u.test(result)
) {
throw new Error(
"Subject body request ID must be at most 128 UTF-8 bytes and contain no control characters",
);
}
return result;
}
function token(value: unknown): string {
const result = text(value, 256);
if (new TextEncoder().encode(result).byteLength > 256) {
throw new Error("Subject body observation token is too long");
}
return result;
}
function flag(value: unknown): boolean {
if (typeof value !== "boolean") {
throw new Error("Subject body flag is invalid");
}
return value;
}
function diagnostics(value: unknown): Diagnostic[] {
if (!Array.isArray(value) || value.length > 128) {
throw new Error("Subject body diagnostics exceed their limit");
}
return value.map((entry) => {
const item = record(entry, ["code", "severity", "message"]);
const severity = item.severity;
if (severity !== "info" && severity !== "warning" && severity !== "error") {
throw new Error("Subject body diagnostic severity is invalid");
}
return {
code: text(item.code),
severity,
message: text(item.message, 65536),
};
});
}
function bodyRef(value: unknown): SubjectBodyRef {
const item = record(value, ["runtime_id", "worker_id", "generation"]);
const generation = text(item.generation, 20);
if (
!/^(0|[1-9][0-9]*)$/.test(generation) ||
(generation.length === 20 && generation > "18446744073709551615")
) {
throw new Error("Subject body generation must be unsigned decimal text");
}
return {
runtime_id: text(item.runtime_id),
worker_id: text(item.worker_id),
generation,
};
}
export function parseSubjectBodyResponse(
value: unknown,
workspaceId: string,
subjectId: string,
): SubjectBodyResponse {
const item = record(value, [
"workspace_id",
"subject_id",
"current",
"observation",
"can_start",
"can_stop",
]);
const workspace = text(item.workspace_id);
const subject = text(item.subject_id);
if (workspace !== workspaceId || subject !== subjectId) {
throw new Error(
"Subject body response does not match the requested target",
);
}
const current = item.current === null ? null : bodyRef(item.current);
const observed = record(item.observation, ["kind", "worker", "diagnostics"]);
let observation: SubjectBodyObservation;
switch (observed.kind) {
case "absent":
record(observed, ["kind"]);
if (current !== null) throw new Error("Absent body cannot have an owner");
observation = { kind: "absent" };
break;
case "unavailable":
record(observed, ["kind", "diagnostics"]);
if (current === null) {
throw new Error("Unavailable body must retain its owner");
}
observation = {
kind: "unavailable",
diagnostics: diagnostics(observed.diagnostics),
};
break;
case "available": {
record(observed, ["kind", "worker"]);
const worker = parseWorkerSummary(observed.worker);
if (
current === null || worker.runtime_id !== current.runtime_id ||
worker.worker_id !== current.worker_id ||
(worker.workspace.workspace_id != null &&
worker.workspace.workspace_id !== workspaceId)
) {
throw new Error("Subject body Worker does not match the current owner");
}
observation = { kind: "available", worker };
break;
}
default:
throw new Error("Subject body observation kind is invalid");
}
return {
workspace_id: workspace,
subject_id: subject,
current,
observation,
can_start: flag(item.can_start),
can_stop: flag(item.can_stop),
};
}
export function parseSubjectBodyMutationResponse(
value: unknown,
workspaceId: string,
subjectId: string,
requestId: string,
): SubjectBodyMutationResponse {
const item = record(value, [
"request_id",
"disposition",
"diagnostics",
"body",
]);
const request_id = parseRequestId(item.request_id);
if (request_id !== requestId) {
throw new Error("Subject body receipt does not match the request");
}
const disposition = item.disposition;
if (
disposition !== "accepted" && disposition !== "rejected" &&
disposition !== "unknown"
) {
throw new Error("Subject body disposition is invalid");
}
return {
request_id,
disposition,
diagnostics: diagnostics(item.diagnostics),
body: item.body === null
? null
: parseSubjectBodyResponse(item.body, workspaceId, subjectId),
};
}
export function parseSubjectBodyStopOutcomeResponse(
value: unknown,
workspaceId: string,
subjectId: string,
requestId: string,
): SubjectBodyStopOutcomeResponse {
const item = record(value, ["request_id", "outcome", "body"]);
const request_id = parseRequestId(item.request_id);
if (request_id !== requestId) {
throw new Error("Subject body stop outcome does not match the request");
}
const outcome = item.outcome;
if (
outcome !== "not_found" && outcome !== "pending" && outcome !== "stopped"
) {
throw new Error("Subject body stop outcome is invalid");
}
return {
request_id,
outcome,
body: item.body === null
? null
: parseSubjectBodyResponse(item.body, workspaceId, subjectId),
};
}
function bodyPath(workspaceId: string, subjectId: string): string {
return workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/body`,
);
}
export class SubjectBodyApiError extends Error {}
async function checkedJson(response: Response): Promise<unknown> {
if (!response.ok) {
// 生のerror bodyを表示せず、公開Diagnosticだけを境界で検査する。
let detail = "";
try {
const error = record(
await readBoundedJson(response, MAX_RESPONSE_BYTES),
["error", "message", "diagnostics"],
);
if (error.diagnostics !== undefined) {
detail = diagnostics(error.diagnostics).map((item) => item.message)
.join("; ").slice(0, 1024);
}
} catch { /* HTTP分類はbodyのdecode失敗でも保持する。 */ }
throw new SubjectBodyApiError(
`Subject body request failed with HTTP ${response.status}${
detail ? `: ${detail}` : ""
}`,
);
}
return readBoundedJson(response, MAX_RESPONSE_BYTES);
}
export async function getSubjectBody(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
): Promise<SubjectBodyResponse> {
return parseSubjectBodyResponse(
await checkedJson(
await fetchFn(bodyPath(workspaceId, subjectId), { cache: "no-store" }),
),
workspaceId,
subjectId,
);
}
async function mutateSubjectBody(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
action: "start" | "stop",
request: SubjectBodyStartRequest | SubjectBodyStopRequest,
): Promise<SubjectBodyMutationResponse> {
const requestId = parseRequestId(request.request_id);
const response = await fetchFn(
`${bodyPath(workspaceId, subjectId)}/${action}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
return parseSubjectBodyMutationResponse(
await checkedJson(response),
workspaceId,
subjectId,
requestId,
);
}
export function startSubjectBody(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjectBodyStartRequest,
): Promise<SubjectBodyMutationResponse> {
return mutateSubjectBody(fetchFn, workspaceId, subjectId, "start", request);
}
export function stopSubjectBody(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjectBodyStopRequest,
): Promise<SubjectBodyMutationResponse> {
return mutateSubjectBody(fetchFn, workspaceId, subjectId, "stop", request);
}
// POSTは凍結入力を照合するためであり、stopの再実行ではない。
export async function getSubjectBodyStopOutcome(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjectBodyStopRequest,
): Promise<SubjectBodyStopOutcomeResponse> {
const requestId = parseRequestId(request.request_id);
const response = await fetchFn(
`${bodyPath(workspaceId, subjectId)}/stop/outcome`,
{
method: "POST",
headers: { "content-type": "application/json" },
cache: "no-store",
body: JSON.stringify(request),
},
);
// 認可拒否・通信失敗・不正な応答をnot_foundに読み替えない。
return parseSubjectBodyStopOutcomeResponse(
await checkedJson(response),
workspaceId,
subjectId,
requestId,
);
}
export function subjectBodyControls(
body: SubjectBodyResponse,
): { start: boolean; stop: boolean } {
if (body.observation.kind === "absent") {
return { start: body.can_start, stop: false };
}
if (body.observation.kind !== "available") {
return { start: false, stop: false };
}
const worker = body.observation.worker;
const observed = worker.availability === "observed" &&
Boolean(worker.restore_observation_token);
return {
start: body.can_start && observed && worker.state === "stopped" &&
worker.worker_state == null,
stop: body.can_stop && observed && worker.worker_state != null &&
["idle", "running", "paused"].includes(worker.state),
};
}
// accountIdはwhoami由来のローカルな隔離境界であり、Serverへ送るactor指定ではない。
export type SubjectBodyAttempt =
& {
accountId: string;
workspaceId: string;
subjectId: string;
}
& (
| { action: "start"; request: SubjectBodyStartRequest }
| { action: "stop"; request: SubjectBodyStopRequest }
);
export function subjectBodyStorageKey(
accountId: string,
workspaceId: string,
subjectId: string,
): string {
// accountを特定できない旧2要素keyは読取り・移行・削除しない。
return `yoi.subject-body:${
JSON.stringify([text(accountId), workspaceId, subjectId])
}`;
}
function parseAttempt(
value: unknown,
accountId: string,
workspaceId: string,
subjectId: string,
): SubjectBodyAttempt {
text(accountId);
const item = record(value, [
"accountId",
"workspaceId",
"subjectId",
"action",
"request",
]);
if (
item.accountId !== accountId || item.workspaceId !== workspaceId ||
item.subjectId !== subjectId
) {
throw new Error(
"Saved Subject body request belongs to a different account or target",
);
}
const input = record(item.request, [
"request_id",
"expected_current",
"expected_observation_token",
]);
const request_id = parseRequestId(input.request_id);
const expected_current = input.expected_current === null
? null
: Object.freeze(bodyRef(input.expected_current));
const expected_observation_token = input.expected_observation_token === null
? null
: token(input.expected_observation_token);
if (
item.action === "stop" && expected_current !== null &&
expected_observation_token !== null
) {
return Object.freeze({
accountId,
workspaceId,
subjectId,
action: "stop",
request: Object.freeze({
request_id,
expected_current,
expected_observation_token,
}),
});
}
if (
item.action === "start" &&
((expected_current === null) === (expected_observation_token === null))
) {
return Object.freeze({
accountId,
workspaceId,
subjectId,
action: "start",
request: Object.freeze({
request_id,
expected_current,
expected_observation_token,
}),
});
}
throw new Error(
"Saved Subject body request is invalid; do not create a replacement request",
);
}
export function createSubjectBodyAttempt(
accountId: string,
body: SubjectBodyResponse,
action: "start" | "stop",
requestId: string,
): SubjectBodyAttempt {
if (!subjectBodyControls(body)[action]) {
throw new Error("Refresh Subject body before this action");
}
return parseAttempt(
{
accountId,
workspaceId: body.workspace_id,
subjectId: body.subject_id,
action,
request: {
request_id: requestId,
expected_current: body.current,
expected_observation_token: body.observation.kind === "available"
? body.observation.worker.restore_observation_token
: null,
},
},
accountId,
body.workspace_id,
body.subject_id,
);
}
export function readSubjectBodyAttempt(
storage: Pick<Storage, "getItem">,
accountId: string,
workspaceId: string,
subjectId: string,
): SubjectBodyAttempt | null {
const raw = storage.getItem(
subjectBodyStorageKey(accountId, workspaceId, subjectId),
);
if (raw === null) return null;
if (raw.length > MAX_SAVED_LENGTH) {
throw new Error("Saved Subject body request is too large");
}
return parseAttempt(JSON.parse(raw), accountId, workspaceId, subjectId);
}
export function saveSubjectBodyAttempt(
storage: Pick<Storage, "getItem" | "setItem">,
accountId: string,
attempt: SubjectBodyAttempt,
): void {
const safe = parseAttempt(
attempt,
accountId,
attempt.workspaceId,
attempt.subjectId,
);
const raw = JSON.stringify(safe);
if (raw.length > MAX_SAVED_LENGTH) {
throw new Error("Subject body request is too large to retain");
}
const previous = readSubjectBodyAttempt(
storage,
accountId,
safe.workspaceId,
safe.subjectId,
);
if (previous !== null && JSON.stringify(previous) !== raw) {
throw new Error(
"An unresolved Subject body request already exists; retry that request",
);
}
storage.setItem(
subjectBodyStorageKey(accountId, safe.workspaceId, safe.subjectId),
raw,
);
}
export function clearSubjectBodyAttempt(
storage: Pick<Storage, "getItem" | "removeItem">,
accountId: string,
attempt: SubjectBodyAttempt,
): void {
// 呼出し時のaccountと凍結入力を、storageアクセスより前に照合する。
const safe = parseAttempt(
attempt,
accountId,
attempt.workspaceId,
attempt.subjectId,
);
const saved = readSubjectBodyAttempt(
storage,
accountId,
safe.workspaceId,
safe.subjectId,
);
if (saved && JSON.stringify(saved) !== JSON.stringify(safe)) {
throw new Error(
"The saved Subject body request changed; do not replace it",
);
}
storage.removeItem(
subjectBodyStorageKey(accountId, safe.workspaceId, safe.subjectId),
);
}
@@ -143,8 +143,8 @@
if (!scope.isCurrent()) return;
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
pushWorkspaceAlert('info', `Stop accepted for ${worker.display_name || worker.label}. Check the refreshed Worker state for confirmation.`, {
title: 'Stop accepted',
});
} catch (cause) {
if (!scope.isCurrent()) return;
@@ -196,13 +196,23 @@ export function canDeleteSidebarWorker(worker: WorkerActionTarget): boolean {
export async function stopSidebarWorker(
workspaceId: string,
worker: WorkerActionTarget,
worker: WorkerActionTarget & { restore_observation_token?: string | null },
fetchFn: FetchFn = fetch,
): Promise<void> {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason: "stopped from Workspace sidebar" }),
body: JSON.stringify({
reason: "stopped from Workspace sidebar",
...(worker.restore_observation_token
? {
stop_intent: {
request_id: crypto.randomUUID(),
expected_observation_token: worker.restore_observation_token,
},
}
: {}),
}),
});
if (!response.ok) throw new Error(await responseError(response));
@@ -239,7 +249,6 @@ export async function deleteSidebarWorker(
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
@@ -224,7 +224,7 @@
<div>
<dt>Worker connection</dt>
<dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<strong>{subject.current_worker_diagnostics?.length ? 'Body observation unavailable' : subject.current_body ? 'Current body assigned' : 'No current body'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
@@ -4,9 +4,11 @@
// behaviorは利用者が管理する本文、surfaceはMemoryからの生成物として分ける。
// ready-empty/ungenerated/stale/failedとrequest failureを区別し、surfaceの現在性を
// 実行中Workerの履歴へ反映済みであることの証明には使わない。診断値は詳細へ分離する。
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import SubjectDelete from '#lib/workspace/memory/SubjectDelete.svelte';
import SubjectBody from '#lib/workspace/memory/SubjectBody.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
SubjektivSubjectCreateError,
@@ -19,6 +21,11 @@
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
let accountId = $state<string | null>(null);
onMount(() => browserSession.subscribe((snapshot) => {
accountId = snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated'
? snapshot.value.actor?.account_id ?? null : null;
}));
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
@@ -190,9 +197,13 @@
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
{#if accountId}
{#key JSON.stringify([accountId, data.workspaceId, data.subjectId])}
<SubjectBody {accountId} workspaceId={data.workspaceId} subjectId={data.subjectId} />
{/key}
{:else}
<p role="status">Confirming account identity before loading body controls…</p>
{/if}
</div>
<div>
<span>Committed Memories</span>