Merge commit '82616c2a41088d13d363b44ac05b2154acdbb87d' into work/t-808-subject-chat
# Conflicts: # crates/server-api/Cargo.toml # web/workspace/deno.json # web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/PageContent.svelte
This commit is contained in:
@@ -623,9 +623,29 @@ pub struct WorkerDeleteResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkerLifecycleRequest {
|
pub struct WorkerLifecycleRequest {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<String>,
|
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)]
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
@@ -633,6 +653,10 @@ pub struct EmptyObjectRequest {}
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct WorkerLifecycleAck {
|
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 worker_ref: WorkerRef,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -1410,13 +1434,29 @@ pub trait RuntimeApi {
|
|||||||
#[body] input: WorkerInput,
|
#[body] input: WorkerInput,
|
||||||
) -> Result<WorkerInputResponse, RuntimeApiError>;
|
) -> 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(
|
async fn stop_worker(
|
||||||
&self,
|
&self,
|
||||||
#[path] worker_id: String,
|
#[path] worker_id: String,
|
||||||
#[body] request: WorkerLifecycleRequest,
|
#[body] request: WorkerLifecycleRequest,
|
||||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
|
) -> 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)]
|
#[post("/v1/workers/{worker_id}/cancel", status = 200, error_status = 400)]
|
||||||
async fn cancel_worker(
|
async fn cancel_worker(
|
||||||
&self,
|
&self,
|
||||||
@@ -1667,6 +1707,47 @@ mod tests {
|
|||||||
assert_eq!(roundtrip, binding);
|
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]
|
#[test]
|
||||||
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
|
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
|
||||||
for invalid in [
|
for invalid in [
|
||||||
@@ -1974,6 +2055,20 @@ mod tests {
|
|||||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
|
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
|
||||||
Err(test_error(501))
|
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(
|
async fn cancel_worker(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: String,
|
_worker_id: String,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ name = "generate_companion_api_types"
|
|||||||
name = "generate_chat_api_types"
|
name = "generate_chat_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_subject_body_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_memory_api_types"
|
name = "generate_memory_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fn main() {
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
server_api::normalized_typescript(server_api::subject_body_api_typescript())
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
|
|||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
mod chat;
|
mod chat;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
|
mod subject_body;
|
||||||
|
pub use subject_body::*;
|
||||||
mod chat_transport;
|
mod chat_transport;
|
||||||
pub use chat_transport::*;
|
pub use chat_transport::*;
|
||||||
mod drive;
|
mod drive;
|
||||||
@@ -250,6 +252,11 @@ impl_openapi_schema!(
|
|||||||
PluginArtifactDeliveryRequest,
|
PluginArtifactDeliveryRequest,
|
||||||
PluginArtifactDeliveryMetadata,
|
PluginArtifactDeliveryMetadata,
|
||||||
PluginWorkerDiagnostics,
|
PluginWorkerDiagnostics,
|
||||||
|
SubjectBodyResponse,
|
||||||
|
SubjectBodyStartRequest,
|
||||||
|
SubjectBodyStopRequest,
|
||||||
|
SubjectBodyMutationResponse,
|
||||||
|
SubjectBodyStopOutcomeResponse,
|
||||||
SubjectPermissionsResponse,
|
SubjectPermissionsResponse,
|
||||||
SubjectPermissionsMutationResponse,
|
SubjectPermissionsMutationResponse,
|
||||||
SubjectPermissionInterruption,
|
SubjectPermissionInterruption,
|
||||||
@@ -1540,6 +1547,45 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] subject_id: String,
|
#[path] subject_id: String,
|
||||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
) -> 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(
|
#[patch(
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -5689,8 +5735,12 @@ pub struct SubjektivSubjectResponse {
|
|||||||
pub memory_fingerprint: String,
|
pub memory_fingerprint: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
/// Current keyed-singleton owner, when one exists. This is a live Yoi
|
/// Current owner remains present even when its Runtime cannot be observed.
|
||||||
/// projection, not state duplicated in the subjektiv store.
|
#[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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_worker: Option<WorkerLaunchWorkerSummary>,
|
pub current_worker: Option<WorkerLaunchWorkerSummary>,
|
||||||
}
|
}
|
||||||
@@ -9678,9 +9728,18 @@ pub struct RuntimeWorkerInputResult {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeWorkerLifecycleRequest {
|
pub struct RuntimeWorkerLifecycleRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop_intent: Option<WorkerStopIntent>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -9690,6 +9749,9 @@ pub struct RuntimeWorkerLifecycleRequest {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeWorkerLifecycleResult {
|
pub struct RuntimeWorkerLifecycleResult {
|
||||||
|
/// Historical receipt replay, not a new stop or evidence of current state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub replayed: bool,
|
||||||
pub state: WorkerOperationState,
|
pub state: WorkerOperationState,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: String,
|
pub worker_id: String,
|
||||||
@@ -11263,7 +11325,7 @@ export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id:
|
|||||||
|
|
||||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
|
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
|
||||||
|
|
||||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_body?: import("./subject-body-api").SubjectBodyRef | null, current_worker_diagnostics?: Array<Diagnostic>, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||||
|
|
||||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -397,16 +397,57 @@ pub struct WorkerDetail {
|
|||||||
/// Acknowledgement returned by stop/cancel lifecycle operations.
|
/// Acknowledgement returned by stop/cancel lifecycle operations.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerLifecycleAck {
|
pub struct WorkerLifecycleAck {
|
||||||
|
/// Historical typed stop receipt; never authorizes cleanup of current resources.
|
||||||
|
#[serde(default)]
|
||||||
|
pub replayed: bool,
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::WorkspaceApiRef;
|
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]
|
#[test]
|
||||||
fn workspace_api_ref_public_schema_contains_no_source_credentials_or_claim_choices() {
|
fn workspace_api_ref_public_schema_contains_no_source_credentials_or_claim_choices() {
|
||||||
let value = serde_json::to_value(WorkspaceApiRef {
|
let value = serde_json::to_value(WorkspaceApiRef {
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ pub enum RuntimeError {
|
|||||||
#[error("worker {worker_id} Restore observation conflicts with the current operation")]
|
#[error("worker {worker_id} Restore observation conflicts with the current operation")]
|
||||||
RestoreObservationConflict { worker_id: WorkerId },
|
RestoreObservationConflict { worker_id: WorkerId },
|
||||||
|
|
||||||
|
#[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")]
|
#[error("worker {worker_id} was not found")]
|
||||||
WorkerNotFound { worker_id: WorkerId },
|
WorkerNotFound { worker_id: WorkerId },
|
||||||
|
|
||||||
|
|||||||
@@ -278,11 +278,27 @@ impl FsRuntimeStore {
|
|||||||
atomic_write_json(&self.runtime_path(), &snapshot, "write runtime snapshot")
|
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(
|
pub(crate) fn write_worker_record(
|
||||||
&self,
|
&self,
|
||||||
worker: &PersistedWorkerRecord,
|
worker: &PersistedWorkerRecord,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
self.ensure_worker_ref(&worker.worker_ref)?;
|
self.ensure_worker_ref(&worker.worker_ref)?;
|
||||||
|
self.validate_worker_record_size(worker)?;
|
||||||
let worker_dir = self.worker_dir(&worker.worker_id);
|
let worker_dir = self.worker_dir(&worker.worker_id);
|
||||||
fs::create_dir_all(&worker_dir).map_err(|source| RuntimeError::StoreIo {
|
fs::create_dir_all(&worker_dir).map_err(|source| RuntimeError::StoreIo {
|
||||||
operation: "create Worker store",
|
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>
|
fn atomic_write_json<T>(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError>
|
||||||
where
|
where
|
||||||
T: Serialize,
|
T: Serialize,
|
||||||
@@ -4024,6 +4067,42 @@ mod tests {
|
|||||||
assert!(error.to_string().contains("exceeds limit"));
|
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]
|
#[test]
|
||||||
fn worker_aggregate_write_distinguishes_pre_and_post_rename_failures() {
|
fn worker_aggregate_write_distinguishes_pre_and_post_rename_failures() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -523,11 +523,7 @@ pub type RuntimeHttpWorkerCompletionsRequest = runtime_api::CompletionRequest;
|
|||||||
pub type RuntimeHttpWorkerCompletionsResponse = runtime_api::CompletionResponse;
|
pub type RuntimeHttpWorkerCompletionsResponse = runtime_api::CompletionResponse;
|
||||||
|
|
||||||
/// Worker lifecycle request body used by stop/cancel endpoints.
|
/// Worker lifecycle request body used by stop/cancel endpoints.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
pub type RuntimeHttpWorkerLifecycleRequest = runtime_api::WorkerLifecycleRequest;
|
||||||
pub struct RuntimeHttpWorkerLifecycleRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker lifecycle acknowledgement response.
|
/// Worker lifecycle acknowledgement response.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[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())? {
|
let ack = match auth_workspace_scope(&state, auth.as_ref())? {
|
||||||
Some(scope) => state
|
Some(scope) => state
|
||||||
.runtime
|
.runtime
|
||||||
.stop_worker_scoped(&scope, &worker_ref, request.reason),
|
.stop_worker_request_scoped(&scope, &worker_ref, request),
|
||||||
None => state.runtime.stop_worker(&worker_ref, request.reason),
|
None => state.runtime.stop_worker_request(&worker_ref, request),
|
||||||
}
|
}
|
||||||
.map_err(RuntimeHttpRestError::runtime)?;
|
.map_err(RuntimeHttpRestError::runtime)?;
|
||||||
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
||||||
@@ -1918,6 +1914,7 @@ async fn cancel_worker(
|
|||||||
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
|
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
|
||||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||||
let request = parse_optional_lifecycle_request(body)?;
|
let request = parse_optional_lifecycle_request(body)?;
|
||||||
|
validate_cancel_request(&request)?;
|
||||||
let ack = match auth_workspace_scope(&state, auth.as_ref())? {
|
let ack = match auth_workspace_scope(&state, auth.as_ref())? {
|
||||||
Some(scope) => state
|
Some(scope) => state
|
||||||
.runtime
|
.runtime
|
||||||
@@ -1942,6 +1939,19 @@ fn worker_ref_for(
|
|||||||
Ok(WorkerRef::new(worker_id))
|
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(
|
fn parse_optional_lifecycle_request(
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> Result<RuntimeHttpWorkerLifecycleRequest, RuntimeHttpRestError> {
|
) -> Result<RuntimeHttpWorkerLifecycleRequest, RuntimeHttpRestError> {
|
||||||
@@ -2160,6 +2170,8 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
return Some("workers:delete");
|
return Some("workers:delete");
|
||||||
}
|
}
|
||||||
if path.ends_with("/stop")
|
if path.ends_with("/stop")
|
||||||
|
|| path.ends_with("/stop/outcome")
|
||||||
|
|| path.ends_with("/stop/admit")
|
||||||
|| path.ends_with("/cancel")
|
|| path.ends_with("/cancel")
|
||||||
|| (path == "/v1/subject-release" && *method == Method::POST)
|
|| (path == "/v1/subject-release" && *method == Method::POST)
|
||||||
{
|
{
|
||||||
@@ -2432,6 +2444,10 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
|||||||
}
|
}
|
||||||
RuntimeError::RuntimeStopped
|
RuntimeError::RuntimeStopped
|
||||||
| RuntimeError::RestoreObservationConflict { .. }
|
| RuntimeError::RestoreObservationConflict { .. }
|
||||||
|
| RuntimeError::RestoreAdmissionBusy { .. }
|
||||||
|
| RuntimeError::StopObservationConflict { .. }
|
||||||
|
| RuntimeError::StopRequestConflict { .. }
|
||||||
|
| RuntimeError::StopAdmissionBusy { .. }
|
||||||
| RuntimeError::RuntimeStoreAlreadyOpen { .. }
|
| RuntimeError::RuntimeStoreAlreadyOpen { .. }
|
||||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||||
| RuntimeError::ExecutionBackendUnavailable { .. }
|
| RuntimeError::ExecutionBackendUnavailable { .. }
|
||||||
@@ -2459,6 +2475,10 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
|
|||||||
RuntimeError::RestoreObservationConflict { .. } => {
|
RuntimeError::RestoreObservationConflict { .. } => {
|
||||||
"restore_observation_conflict".to_string()
|
"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::RuntimeStoreAlreadyOpen { .. } => "runtime_store_already_open".to_string(),
|
||||||
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
|
RuntimeError::WorkerNotFound { .. } => "worker_not_found".to_string(),
|
||||||
RuntimeError::WorkerExecutionUnavailable { .. } => {
|
RuntimeError::WorkerExecutionUnavailable { .. } => {
|
||||||
@@ -3728,13 +3748,80 @@ mod tests {
|
|||||||
input.submission_request_id
|
input.submission_request_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
client
|
let stop_request = runtime_api::WorkerLifecycleRequest {
|
||||||
.stop_worker(
|
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(),
|
generated_worker_id.clone(),
|
||||||
runtime_api::WorkerLifecycleRequest::default(),
|
runtime_api::WorkerLifecycleRequest::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
client
|
||||||
.restore_worker(
|
.restore_worker(
|
||||||
generated_worker_id.clone(),
|
generated_worker_id.clone(),
|
||||||
@@ -3752,6 +3839,32 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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();
|
let conflict = client.create_worker(conflicting_request).await.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
|
|||||||
@@ -678,11 +678,12 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||||
.map_err(api_error)?
|
.map_err(api_error)?
|
||||||
{
|
{
|
||||||
Some(scope) => self
|
Some(scope) => {
|
||||||
.state
|
self.state
|
||||||
.runtime
|
.runtime
|
||||||
.stop_worker_scoped(&scope, &worker_ref, value.reason),
|
.stop_worker_request_scoped(&scope, &worker_ref, value)
|
||||||
None => self.state.runtime.stop_worker(&worker_ref, value.reason),
|
}
|
||||||
|
None => self.state.runtime.stop_worker_request(&worker_ref, value),
|
||||||
}
|
}
|
||||||
.map_err(RuntimeHttpRestError::runtime)
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
.map_err(api_error)?;
|
.map_err(api_error)?;
|
||||||
@@ -690,11 +691,57 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
Ok(runtime_api::WorkerLifecycleResponse { ack })
|
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(
|
async fn cancel_worker(
|
||||||
&self,
|
&self,
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
value: runtime_api::WorkerLifecycleRequest,
|
value: runtime_api::WorkerLifecycleRequest,
|
||||||
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
|
) -> 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 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())
|
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||||
.map_err(api_error)?
|
.map_err(api_error)?
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
|||||||
runtime_id,
|
runtime_id,
|
||||||
worker_id,
|
worker_id,
|
||||||
request: server_api::RuntimeWorkerLifecycleRequest {
|
request: server_api::RuntimeWorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason,
|
reason,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -285,6 +286,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
|||||||
runtime_id,
|
runtime_id,
|
||||||
worker_id,
|
worker_id,
|
||||||
request: server_api::RuntimeWorkerLifecycleRequest {
|
request: server_api::RuntimeWorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason,
|
reason,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -711,6 +711,8 @@ pub struct WorkerStopResult {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerLifecycleRequest {
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -719,6 +721,8 @@ pub struct WorkerLifecycleRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerLifecycleResult {
|
pub struct WorkerLifecycleResult {
|
||||||
|
#[serde(default)]
|
||||||
|
pub replayed: bool,
|
||||||
pub state: InternalWorkerOperationState,
|
pub state: InternalWorkerOperationState,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
@@ -878,6 +882,14 @@ impl RuntimeRegistryError {
|
|||||||
} => {
|
} => {
|
||||||
if code == "restore_observation_conflict" {
|
if code == "restore_observation_conflict" {
|
||||||
Error::RestoreObservationConflict
|
Error::RestoreObservationConflict
|
||||||
|
} else if matches!(
|
||||||
|
code.as_str(),
|
||||||
|
"stop_observation_conflict"
|
||||||
|
| "stop_request_conflict"
|
||||||
|
| "stop_admission_busy"
|
||||||
|
| "restore_admission_busy"
|
||||||
|
) {
|
||||||
|
Error::RepositoryConflict(message)
|
||||||
} else {
|
} else {
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id,
|
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",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_worker(
|
fn stop_worker(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
_request: WorkerLifecycleRequest,
|
_request: WorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Unsupported,
|
state: InternalWorkerOperationState::Unsupported,
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic(
|
||||||
@@ -1212,6 +1249,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
_request: WorkerLifecycleRequest,
|
_request: WorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Unsupported,
|
state: InternalWorkerOperationState::Unsupported,
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic(
|
||||||
@@ -2119,6 +2157,40 @@ impl RuntimeRegistry {
|
|||||||
Ok(runtime.worker_completions(worker_id, request))
|
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(
|
pub fn stop_worker(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
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",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
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(
|
fn stop_worker(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -3250,8 +3372,15 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
match self.runtime.stop_worker(&worker_ref, request.reason) {
|
match self.runtime.stop_worker_request(
|
||||||
Ok(_) => WorkerLifecycleResult {
|
&worker_ref,
|
||||||
|
runtime_api::WorkerLifecycleRequest {
|
||||||
|
reason: request.reason,
|
||||||
|
stop_intent: request.stop_intent,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(response) => WorkerLifecycleResult {
|
||||||
|
replayed: response.replayed,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -3295,6 +3424,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
};
|
};
|
||||||
match self.runtime.cancel_worker(&worker_ref, request.reason) {
|
match self.runtime.cancel_worker(&worker_ref, request.reason) {
|
||||||
Ok(_) => WorkerLifecycleResult {
|
Ok(_) => WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -4326,6 +4456,8 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
|||||||
return "workers:input";
|
return "workers:input";
|
||||||
}
|
}
|
||||||
if path.ends_with("/stop")
|
if path.ends_with("/stop")
|
||||||
|
|| path.ends_with("/stop/admit")
|
||||||
|
|| path.ends_with("/stop/outcome")
|
||||||
|| path.ends_with("/cancel")
|
|| path.ends_with("/cancel")
|
||||||
|| (path == "/v1/subject-release" && method == "POST")
|
|| (path == "/v1/subject-release" && method == "POST")
|
||||||
{
|
{
|
||||||
@@ -4899,6 +5031,7 @@ impl RemoteWorkerRuntime {
|
|||||||
response: worker_runtime::catalog::WorkerLifecycleAck,
|
response: worker_runtime::catalog::WorkerLifecycleAck,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: response.replayed,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||||
diagnostics: vec![diagnostic(
|
diagnostics: vec![diagnostic(
|
||||||
@@ -5574,6 +5707,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(
|
fn stop_worker(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -5581,6 +5748,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
let body = runtime_api::WorkerLifecycleRequest {
|
let body = runtime_api::WorkerLifecycleRequest {
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
|
stop_intent: request.stop_intent,
|
||||||
};
|
};
|
||||||
let worker_id_owned = worker_id.to_string();
|
let worker_id_owned = worker_id.to_string();
|
||||||
match self
|
match self
|
||||||
@@ -5606,6 +5774,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
let body = runtime_api::WorkerLifecycleRequest {
|
let body = runtime_api::WorkerLifecycleRequest {
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
|
stop_intent: request.stop_intent,
|
||||||
};
|
};
|
||||||
let worker_id_owned = worker_id.to_string();
|
let worker_id_owned = worker_id.to_string();
|
||||||
match self
|
match self
|
||||||
@@ -6399,6 +6568,7 @@ fn embedded_lifecycle_rejected(
|
|||||||
diagnostic: RuntimeDiagnostic,
|
diagnostic: RuntimeDiagnostic,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Rejected,
|
state: InternalWorkerOperationState::Rejected,
|
||||||
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![diagnostic],
|
||||||
@@ -6411,6 +6581,7 @@ fn remote_lifecycle_rejected(
|
|||||||
diagnostic: RuntimeDiagnostic,
|
diagnostic: RuntimeDiagnostic,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Rejected,
|
state: InternalWorkerOperationState::Rejected,
|
||||||
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
|
worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()),
|
||||||
diagnostics: vec![diagnostic],
|
diagnostics: vec![diagnostic],
|
||||||
@@ -6484,6 +6655,26 @@ fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
|
|||||||
|
|
||||||
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
|
||||||
match error {
|
match error {
|
||||||
|
EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic(
|
||||||
|
"restore_admission_busy",
|
||||||
|
HostDiagnosticSeverity::Warning,
|
||||||
|
"Worker lifecycle admission is busy; retry the same Restore intent",
|
||||||
|
),
|
||||||
|
EmbeddedRuntimeError::StopAdmissionBusy { .. } => diagnostic(
|
||||||
|
"stop_admission_busy",
|
||||||
|
HostDiagnosticSeverity::Warning,
|
||||||
|
"Worker lifecycle admission is busy; retry the same stop intent",
|
||||||
|
),
|
||||||
|
EmbeddedRuntimeError::StopObservationConflict { .. } => diagnostic(
|
||||||
|
"stop_observation_conflict",
|
||||||
|
HostDiagnosticSeverity::Warning,
|
||||||
|
"Stop observation changed; reload before a new intent",
|
||||||
|
),
|
||||||
|
EmbeddedRuntimeError::StopRequestConflict { .. } => diagnostic(
|
||||||
|
"stop_request_conflict",
|
||||||
|
HostDiagnosticSeverity::Warning,
|
||||||
|
"Stop request ID was already used with different input",
|
||||||
|
),
|
||||||
EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic(
|
EmbeddedRuntimeError::RestoreObservationConflict { .. } => diagnostic(
|
||||||
"restore_observation_conflict",
|
"restore_observation_conflict",
|
||||||
HostDiagnosticSeverity::Warning,
|
HostDiagnosticSeverity::Warning,
|
||||||
@@ -7288,6 +7479,7 @@ mod tests {
|
|||||||
assert!(value.get("worker").is_none());
|
assert!(value.get("worker").is_none());
|
||||||
|
|
||||||
let lifecycle = WorkerLifecycleResult {
|
let lifecycle = WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new("arcadia", "30"),
|
worker: RuntimeWorkerRef::new("arcadia", "30"),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -8620,6 +8812,12 @@ mod tests {
|
|||||||
),
|
),
|
||||||
"workers:read"
|
"workers:read"
|
||||||
);
|
);
|
||||||
|
for suffix in ["stop", "stop/admit", "stop/outcome"] {
|
||||||
|
assert_eq!(
|
||||||
|
workspace_runtime_operation("POST", &format!("/v1/workers/{worker_id}/{suffix}")),
|
||||||
|
"workers:stop"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace_runtime_operation("POST", "/v1/session-public/material/retention/erase"),
|
workspace_runtime_operation("POST", "/v1/session-public/material/retention/erase"),
|
||||||
"workers:delete"
|
"workers:delete"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ mod backend_job_profile;
|
|||||||
mod chat;
|
mod chat;
|
||||||
mod external_operation_audit;
|
mod external_operation_audit;
|
||||||
mod subject_authorization;
|
mod subject_authorization;
|
||||||
|
mod subject_body;
|
||||||
mod subjektiv_deletion;
|
mod subjektiv_deletion;
|
||||||
mod subjektiv_job;
|
mod subjektiv_job;
|
||||||
mod subjektiv_journal;
|
mod subjektiv_journal;
|
||||||
@@ -659,6 +660,77 @@ impl WorkdirSessionRegistry {
|
|||||||
.collect()
|
.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(¤t.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> {
|
fn take_worker(&mut self, worker: &RuntimeWorkerRef) -> Vec<RegisteredWorkdirSession> {
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
if let Some(session) = self.attachments.remove(worker) {
|
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(
|
async fn close_worker_workdir_sessions(
|
||||||
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
registry: &Arc<Mutex<WorkdirSessionRegistry>>,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -2220,6 +2303,7 @@ impl WorkerRemovalService {
|
|||||||
return (false, diagnostics);
|
return (false, diagnostics);
|
||||||
};
|
};
|
||||||
let lifecycle_request = WorkerLifecycleRequest {
|
let lifecycle_request = WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("Backend spawn finalize failed; compensating Runtime Worker".to_string()),
|
reason: Some("Backend spawn finalize failed; compensating Runtime Worker".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
};
|
};
|
||||||
@@ -5513,6 +5597,7 @@ impl WorkspaceApi {
|
|||||||
match self.runtime.stop_worker(
|
match self.runtime.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(format!(
|
reason: Some(format!(
|
||||||
"Backend Job attempt {attempt_id} reached terminal state"
|
"Backend Job attempt {attempt_id} reached terminal state"
|
||||||
)),
|
)),
|
||||||
@@ -6580,6 +6665,15 @@ impl WorkspaceApi {
|
|||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
request: server_api::WorkerRestoreRequest,
|
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> {
|
) -> ApiResult<InternalWorkerRestoreResult> {
|
||||||
if request.request_id.trim().is_empty()
|
if request.request_id.trim().is_empty()
|
||||||
|| request.request_id.len() > 512
|
|| request.request_id.len() > 512
|
||||||
@@ -6591,6 +6685,19 @@ impl WorkspaceApi {
|
|||||||
)
|
)
|
||||||
.into());
|
.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.
|
// Auth is checked by WorkspaceWorker before this read-only owner dispatch.
|
||||||
// Existing owners reach receipts/reconciliation before NEW launch eligibility.
|
// Existing owners reach receipts/reconciliation before NEW launch eligibility.
|
||||||
let (recovered, owned_preparation) = self
|
let (recovered, owned_preparation) = self
|
||||||
@@ -6608,6 +6715,24 @@ impl WorkspaceApi {
|
|||||||
result.worker = self.current_restore_summary(worker, result.worker.is_some());
|
result.worker = self.current_restore_summary(worker, result.worker.is_some());
|
||||||
return Ok(result);
|
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 {
|
let preparation = if let Some(preparation) = owned_preparation {
|
||||||
preparation
|
preparation
|
||||||
} else {
|
} else {
|
||||||
@@ -6642,6 +6767,10 @@ impl WorkspaceApi {
|
|||||||
Error::Config("Runtime did not retain admitted Restore preparation".into())
|
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
|
let mut restored = self
|
||||||
.runtime
|
.runtime
|
||||||
.restore_worker(
|
.restore_worker(
|
||||||
@@ -7703,6 +7832,16 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||||
service.clone(),
|
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_subject_behavior_update(service.clone()))
|
||||||
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
@@ -8335,6 +8474,12 @@ fn worker_lifecycle_request_from_api(
|
|||||||
request: server_api::RuntimeWorkerLifecycleRequest,
|
request: server_api::RuntimeWorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleRequest {
|
) -> WorkerLifecycleRequest {
|
||||||
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,
|
reason: request.reason,
|
||||||
ticket_assignment: request
|
ticket_assignment: request
|
||||||
.ticket_assignment
|
.ticket_assignment
|
||||||
@@ -8443,6 +8588,7 @@ fn worker_lifecycle_result_to_api(
|
|||||||
result: WorkerLifecycleResult,
|
result: WorkerLifecycleResult,
|
||||||
) -> server_api::RuntimeWorkerLifecycleResult {
|
) -> server_api::RuntimeWorkerLifecycleResult {
|
||||||
server_api::RuntimeWorkerLifecycleResult {
|
server_api::RuntimeWorkerLifecycleResult {
|
||||||
|
replayed: result.replayed,
|
||||||
state: result.state.into(),
|
state: result.state.into(),
|
||||||
runtime_id: result.worker.runtime_id,
|
runtime_id: result.worker.runtime_id,
|
||||||
worker_id: result.worker.worker_id,
|
worker_id: result.worker.worker_id,
|
||||||
@@ -10074,6 +10220,81 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.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(
|
async fn subjektiv_subject_behavior_update(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -16266,6 +16487,7 @@ async fn compensate_manual_ticket_worker_binding(
|
|||||||
match api.runtime.stop_worker(
|
match api.runtime.stop_worker(
|
||||||
worker,
|
worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(
|
reason: Some(
|
||||||
"roll back failed manual Worker assignment capability binding".to_string(),
|
"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(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(format!(
|
reason: Some(format!(
|
||||||
"bind authoritative Workdir capabilities for Ticket {} manual Worker assignment",
|
"bind authoritative Workdir capabilities for Ticket {} manual Worker assignment",
|
||||||
record.ticket_id
|
record.ticket_id
|
||||||
@@ -17329,6 +17552,7 @@ async fn cancel_ticket_worker(
|
|||||||
match api.runtime.cancel_worker(
|
match api.runtime.cancel_worker(
|
||||||
worker,
|
worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(format!("Ticket implementation cancelled: {reason}")),
|
reason: Some(format!("Ticket implementation cancelled: {reason}")),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -23815,16 +24039,28 @@ fn subjektiv_subject_response(
|
|||||||
let memory_fingerprint = open_subjektiv_store(api)?
|
let memory_fingerprint = open_subjektiv_store(api)?
|
||||||
.memory_fingerprint(&subject.id)
|
.memory_fingerprint(&subject.id)
|
||||||
.map_err(subjektiv_store_error)?;
|
.map_err(subjektiv_store_error)?;
|
||||||
let current_worker = crate::subjektiv::subject_worker_singleton_key(&subject.id)
|
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id)
|
||||||
.ok()
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
.and_then(|key| {
|
// Only an authoritative absent owner means no body. A failed lookup must
|
||||||
api.store
|
// not invite a second launch by presenting an occupied singleton as empty.
|
||||||
.current_worker_singleton_owner(&api.config.workspace_id, &key)
|
let owner = api
|
||||||
.ok()
|
.store
|
||||||
.flatten()
|
.current_worker_singleton_owner(&api.config.workspace_id, &singleton_key)?;
|
||||||
})
|
let current_body = owner.as_ref().map(subject_body::reference);
|
||||||
.and_then(|lease| api.runtime.worker(&lease.worker).ok())
|
let (current_worker, current_worker_diagnostics) = match owner {
|
||||||
.map(worker_launch_worker_summary);
|
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 {
|
Ok(server_api::SubjektivSubjectResponse {
|
||||||
id: subject.id,
|
id: subject.id,
|
||||||
role: subject.role.as_str().to_string(),
|
role: subject.role.as_str().to_string(),
|
||||||
@@ -23838,6 +24074,8 @@ fn subjektiv_subject_response(
|
|||||||
},
|
},
|
||||||
created_at: subject.created_at,
|
created_at: subject.created_at,
|
||||||
updated_at: subject.updated_at,
|
updated_at: subject.updated_at,
|
||||||
|
current_body,
|
||||||
|
current_worker_diagnostics,
|
||||||
current_worker,
|
current_worker,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -37516,13 +37754,24 @@ fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagn
|
|||||||
|
|
||||||
fn sync_linked_workdir_after_worker_stop(
|
fn sync_linked_workdir_after_worker_stop(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_id: &str,
|
worker: &RuntimeWorkerRef,
|
||||||
worker_record: &WorkerRegistryRecord,
|
observed_links: &[WorkerWorkdirLinkRecord],
|
||||||
) -> ApiResult<()> {
|
) -> 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
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, &worker_record.worker)?;
|
.list_worker_workdir_links(api.workspace_id(), worker)?;
|
||||||
for link in links {
|
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
|
let result = api
|
||||||
.runtime
|
.runtime
|
||||||
.working_directory(runtime_id, link.workdir_id.as_str())
|
.working_directory(runtime_id, link.workdir_id.as_str())
|
||||||
@@ -42275,6 +42524,7 @@ mod tests {
|
|||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -42287,6 +42537,7 @@ mod tests {
|
|||||||
_request: WorkerLifecycleRequest,
|
_request: WorkerLifecycleRequest,
|
||||||
) -> WorkerLifecycleResult {
|
) -> WorkerLifecycleResult {
|
||||||
WorkerLifecycleResult {
|
WorkerLifecycleResult {
|
||||||
|
replayed: false,
|
||||||
state: InternalWorkerOperationState::Accepted,
|
state: InternalWorkerOperationState::Accepted,
|
||||||
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
@@ -42956,15 +43207,23 @@ mod tests {
|
|||||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||||
let stopped = Arc::new(tokio::sync::Notify::new());
|
let stopped = Arc::new(tokio::sync::Notify::new());
|
||||||
let notification = stopped.clone();
|
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(
|
let identity = RuntimeWorkerRef::new(
|
||||||
WorkdirlessFixtureRuntime::RUNTIME_ID,
|
WorkdirlessFixtureRuntime::RUNTIME_ID,
|
||||||
Uuid::now_v7().to_string(),
|
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
|
api.store
|
||||||
.upsert_worker_registry(&WorkerRegistryRecord {
|
.upsert_worker_registry(&WorkerRegistryRecord {
|
||||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||||
@@ -42998,11 +43257,8 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert_attachment(identity.clone(), session);
|
.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.
|
// 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 =
|
let worker =
|
||||||
WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||||
let waiter = tokio::spawn(async move {
|
let waiter = tokio::spawn(async move {
|
||||||
@@ -43010,6 +43266,7 @@ mod tests {
|
|||||||
.stop(
|
.stop(
|
||||||
&WorkerOperationContext::Backend,
|
&WorkerOperationContext::Backend,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -43034,7 +43291,7 @@ mod tests {
|
|||||||
.attachments
|
.attachments
|
||||||
.contains_key(&identity)
|
.contains_key(&identity)
|
||||||
);
|
);
|
||||||
drop(session_guard);
|
drop(session_guard.lock().unwrap().take());
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(2),
|
std::time::Duration::from_secs(2),
|
||||||
api.host_executors.host().unwrap().drain(),
|
api.host_executors.host().unwrap().drain(),
|
||||||
@@ -43053,6 +43310,156 @@ 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();
|
||||||
|
let runtime = 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()
|
||||||
|
};
|
||||||
|
runtime
|
||||||
|
.workers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(worker_summary_from_registry(
|
||||||
|
&api.store
|
||||||
|
.get_worker_registry(TEST_WORKSPACE_ID, &identity)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
));
|
||||||
|
api.runtime.register_or_replace(runtime);
|
||||||
|
let worker =
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn command_session_survives_attachment_refresh_until_worker_revocation() {
|
async fn command_session_survives_attachment_refresh_until_worker_revocation() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
@@ -45271,6 +45678,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("exercise logical attachment reconciliation".to_string()),
|
reason: Some("exercise logical attachment reconciliation".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -45411,6 +45819,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("production Orchestrator restore regression test".to_string()),
|
reason: Some("production Orchestrator restore regression test".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -46515,6 +46924,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("singleton restore test".to_string()),
|
reason: Some("singleton restore test".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -46972,6 +47382,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&target,
|
&target,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("prepare archive revocation regression".to_string()),
|
reason: Some("prepare archive revocation regression".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -48072,6 +48483,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("test subject Worker restart".to_string()),
|
reason: Some("test subject Worker restart".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -49848,6 +50260,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&forged_internal.worker,
|
&forged_internal.worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("remove forged Orchestrator claimant".to_string()),
|
reason: Some("remove forged Orchestrator claimant".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -57328,6 +57741,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&singleton_worker,
|
&singleton_worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("replace completed Memory singleton".to_string()),
|
reason: Some("replace completed Memory singleton".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -61654,6 +62068,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&target,
|
&target,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("prepare WorkerRemove regression".to_string()),
|
reason: Some("prepare WorkerRemove regression".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -65286,6 +65701,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("prepare singleton cleanup".to_string()),
|
reason: Some("prepare singleton cleanup".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -65411,6 +65827,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("prepare cleanup projection regression".to_string()),
|
reason: Some("prepare cleanup projection regression".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -69034,6 +69451,7 @@ mod tests {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker_ref,
|
&worker_ref,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("retire Worker after rejected automatic restore".to_string()),
|
reason: Some("retire Worker after rejected automatic restore".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -885,6 +885,7 @@ pub(super) async fn interrupt_subject_workers(
|
|||||||
let cancellation = match api.host_executors.host() {
|
let cancellation = match api.host_executors.host() {
|
||||||
Ok(host) => host.run(move || -> ApiResult<_> {
|
Ok(host) => host.run(move || -> ApiResult<_> {
|
||||||
Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest {
|
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,
|
reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None,
|
||||||
}).map_err(RuntimeRegistryError::into_error)?)
|
}).map_err(RuntimeRegistryError::into_error)?)
|
||||||
}).await,
|
}).await,
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! 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}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_diagnostics(code: &str, error: ApiError) -> Vec<server_api::Diagnostic> {
|
||||||
|
let mut diagnostics = vec![diagnostic(code, error.error.to_string())];
|
||||||
|
diagnostics.extend(error.diagnostics.into_iter().map(Into::into));
|
||||||
|
diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(
|
||||||
|
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,
|
||||||
|
failure_diagnostics("subject_body_stop_outcome_unknown", error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
failure_diagnostics("subject_body_restore_outcome_unknown", error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
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(server_api::authorization::SUBJECT_CONVERSATION_PROFILE.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(_)
|
||||||
|
| Error::SubjektivSubjectNotFound(_)
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Err(error) => (
|
||||||
|
SubjectBodyDisposition::Unknown,
|
||||||
|
failure_diagnostics("subject_body_start_outcome_unknown", error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Ok(finish(
|
||||||
|
api,
|
||||||
|
context,
|
||||||
|
workspace,
|
||||||
|
subject,
|
||||||
|
request_id,
|
||||||
|
disposition,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.await)
|
||||||
|
}
|
||||||
@@ -240,6 +240,7 @@ async fn remove_worker(
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
worker,
|
worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(format!("Explicit Subject deletion {operation}")),
|
reason: Some(format!("Explicit Subject deletion {operation}")),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&owner.worker,
|
&owner.worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some(
|
reason: Some(
|
||||||
"Retire legacy subjektiv consolidator after foreground completion"
|
"Retire legacy subjektiv consolidator after foreground completion"
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("retention diagnostic fixture".into()),
|
reason: Some("retention diagnostic fixture".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1441,7 +1441,22 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
let worker_ref = RuntimeWorkerRef::new("connected-chat", worker);
|
let worker_ref = RuntimeWorkerRef::new("connected-chat", worker);
|
||||||
let lifecycle_path =
|
let lifecycle_path =
|
||||||
format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}");
|
format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}");
|
||||||
let stopped = f.post(&format!("{lifecycle_path}/stop"), json!({})).await;
|
let stop_observation = f
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.worker(&worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.restore_observation_token
|
||||||
|
.unwrap();
|
||||||
|
let stopped = f
|
||||||
|
.post(
|
||||||
|
&format!("{lifecycle_path}/stop"),
|
||||||
|
json!({"stop_intent": {
|
||||||
|
"request_id": "explicit-chat-stop",
|
||||||
|
"expected_observation_token": stop_observation,
|
||||||
|
}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(stopped["state"], "accepted");
|
assert_eq!(stopped["state"], "accepted");
|
||||||
let stopped_wire =
|
let stopped_wire =
|
||||||
json!({"operation_id":"connected-stopped","text":"CHAT_STOPPED_SAVED_NOT_NOTIFIED"});
|
json!({"operation_id":"connected-stopped","text":"CHAT_STOPPED_SAVED_NOT_NOTIFIED"});
|
||||||
@@ -1571,7 +1586,21 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"revocation cannot be repaired by profile or create replay: {body}"
|
"revocation cannot be repaired by profile or create replay: {body}"
|
||||||
);
|
);
|
||||||
f.post(&format!("{lifecycle_path}/stop"), json!({})).await;
|
let stop_observation = f
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.worker(&worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.restore_observation_token
|
||||||
|
.unwrap();
|
||||||
|
f.post(
|
||||||
|
&format!("{lifecycle_path}/stop"),
|
||||||
|
json!({"stop_intent": {
|
||||||
|
"request_id": "explicit-chat-stop-after-revocation",
|
||||||
|
"expected_observation_token": stop_observation,
|
||||||
|
}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let token = f
|
let token = f
|
||||||
.api
|
.api
|
||||||
.runtime
|
.runtime
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ async fn browser_method_queue_overflow_fails_fast_and_does_not_pin_session_clean
|
|||||||
worker.stop(
|
worker.stop(
|
||||||
&WorkerOperationContext::Backend,
|
&WorkerOperationContext::Backend,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("cleanup-after-method-overflow".into()),
|
reason: Some("cleanup-after-method-overflow".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -674,6 +675,7 @@ async fn assert_same_worker_reconnect_and_stop(
|
|||||||
worker.stop(
|
worker.stop(
|
||||||
&WorkerOperationContext::Backend,
|
&WorkerOperationContext::Backend,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("cleanup-after-stalled-protocol".into()),
|
reason: Some("cleanup-after-stalled-protocol".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async fn restore_keeps_offline_external_and_unmaterialized_repository_attachment
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -186,6 +187,7 @@ async fn restore_keeps_offline_external_and_unmaterialized_repository_attachment
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ async fn restored_runtime_parent_lists_and_inputs_existing_child() {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&parent,
|
&parent,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("restart parent".into()),
|
reason: Some("restart parent".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ use super::*;
|
|||||||
mod chat_delegation_connected;
|
mod chat_delegation_connected;
|
||||||
#[path = "chat_tests.rs"]
|
#[path = "chat_tests.rs"]
|
||||||
mod chat_tests;
|
mod chat_tests;
|
||||||
|
#[path = "subject_body_cleanup_connected_tests.rs"]
|
||||||
|
mod subject_body_cleanup_connected;
|
||||||
|
#[path = "subject_body_connected_tests.rs"]
|
||||||
|
mod subject_body_connected;
|
||||||
|
#[path = "subject_body_tests.rs"]
|
||||||
|
mod subject_body_tests;
|
||||||
#[path = "subject_job_model_supply_tests.rs"]
|
#[path = "subject_job_model_supply_tests.rs"]
|
||||||
mod subject_job_model_supply;
|
mod subject_job_model_supply;
|
||||||
#[path = "subject_permission_management_tests.rs"]
|
#[path = "subject_permission_management_tests.rs"]
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
//! Connected stop/restore cleanup regression for the normal Server library target.
|
||||||
|
//!
|
||||||
|
//! The entry is authenticated Backend Worker control; Workspace capability signing,
|
||||||
|
//! Runtime HTTP, FsRuntimeStore, ProfileRuntimeWorkerFactory/Controller, canonical
|
||||||
|
//! Session restore, Git materialization and RemoteWorkdir sessions are production
|
||||||
|
//! implementations. Only the external model is scripted (no turn is submitted),
|
||||||
|
//! and a byte-preserving loopback proxy gates the first stop dispatch and observes
|
||||||
|
//! the actual Runtime ACK. Neither execution nor restore state is substituted.
|
||||||
|
//!
|
||||||
|
//! The test owns the existing Worker session fence after stop's resource snapshot
|
||||||
|
//! and before its ACK. While ACKed cleanup cannot reacquire that fence, it restores
|
||||||
|
//! the same ordinary Worker through signed Runtime HTTP, opens real provider
|
||||||
|
//! sessions/commands, and rebinds the same alias/Workdir through the SQLite lifetime
|
||||||
|
//! API. Registry insertion and rebind are fixture setup under that fence, not a
|
||||||
|
//! claim about the browser attachment-management endpoint or its authorization.
|
||||||
|
//! Provider calls still use the normal signed RemoteWorkdir HTTP boundary; there
|
||||||
|
//! is no unsigned router, fake ACK, synthetic restore result, or Subject-only
|
||||||
|
//! execution path. Successful Runtime DELETEs and subsequent provider operations
|
||||||
|
//! distinguish old resource release from survival of the new lifetimes.
|
||||||
|
//!
|
||||||
|
//! Run with the `subject_body_cleanup_connected` library filter. The launching
|
||||||
|
//! shell/normal runner must supply the isolated restore paths required by
|
||||||
|
//! tests/support/restore_environment.rs; this module never changes process env.
|
||||||
|
//! This is an in-process connection/race test, not an OS-crash or live-model test.
|
||||||
|
//! The command fixture requires the normal POSIX shell and `tail` on PATH.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Notify, Semaphore};
|
||||||
|
|
||||||
|
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(45);
|
||||||
|
const ALIAS: &str = "workdir";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RuntimeHttpFailure {
|
||||||
|
method: Method,
|
||||||
|
path: String,
|
||||||
|
status: StatusCode,
|
||||||
|
code: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
closed_before_response: bool,
|
||||||
|
// Match the shared harness's nested-envelope view without discarding the
|
||||||
|
// flat Workdir envelope's actual code/message from our diagnostic evidence.
|
||||||
|
harness_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeHttpFailure {
|
||||||
|
fn is_expected_creation_probe(&self, workdir: &str) -> bool {
|
||||||
|
self.method == Method::GET
|
||||||
|
&& self.path == format!("/v1/working-directories/{workdir}")
|
||||||
|
&& self.status == StatusCode::NOT_FOUND
|
||||||
|
&& self.code.as_deref() == Some("working_directory_not_found")
|
||||||
|
&& self.message.as_deref()
|
||||||
|
== Some("working_directory_not_found: Runtime working directory was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_expected_closed_session(&self) -> bool {
|
||||||
|
self.method == Method::POST
|
||||||
|
&& self.path.starts_with("/v1/workdir-sessions/")
|
||||||
|
&& self.path.ends_with("/operations")
|
||||||
|
&& self.status == StatusCode::NOT_FOUND
|
||||||
|
&& self.code.as_deref() == Some("not_found")
|
||||||
|
&& self.message.as_deref() == Some("Workdir session was not found")
|
||||||
|
&& self.closed_before_response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StopTransport {
|
||||||
|
upstream: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
hold_first_stop: AtomicBool,
|
||||||
|
dispatched: Notify,
|
||||||
|
release: Semaphore,
|
||||||
|
acked: Notify,
|
||||||
|
acks: Mutex<Vec<runtime_api::WorkerLifecycleAck>>,
|
||||||
|
opened: Mutex<Vec<String>>,
|
||||||
|
closed: Mutex<Vec<String>>,
|
||||||
|
failures: Mutex<Vec<RuntimeHttpFailure>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StopTransport {
|
||||||
|
async fn forward(State(state): State<Arc<Self>>, request: Request<Body>) -> Response {
|
||||||
|
let method = request.method().clone();
|
||||||
|
let path = request.uri().path().to_owned();
|
||||||
|
let is_stop = request.method() == Method::POST
|
||||||
|
&& path.starts_with("/v1/workers/")
|
||||||
|
&& path.ends_with("/stop");
|
||||||
|
if is_stop && state.hold_first_stop.swap(false, Ordering::SeqCst) {
|
||||||
|
// stop_impl has already released its pre-dispatch session fence.
|
||||||
|
state.dispatched.notify_one();
|
||||||
|
state.release.acquire().await.unwrap().forget();
|
||||||
|
}
|
||||||
|
let is_open = request.method() == Method::POST
|
||||||
|
&& path.starts_with("/v1/working-directories/")
|
||||||
|
&& path.ends_with("/sessions");
|
||||||
|
let is_close =
|
||||||
|
request.method() == Method::DELETE && path.starts_with("/v1/workdir-sessions/");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let body = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
|
||||||
|
// Keep method, URI, signed headers and exact body bytes. Runtime's
|
||||||
|
// production verifier, rather than this proxy, decides authorization.
|
||||||
|
let response = state
|
||||||
|
.client
|
||||||
|
.request(parts.method, format!("{}{}", state.upstream, parts.uri))
|
||||||
|
.headers(parts.headers)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
let bytes = response.bytes().await.unwrap();
|
||||||
|
if status.is_client_error() || status.is_server_error() {
|
||||||
|
let body: Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
// REST uses {error:{code,message}}, whereas Workdir transport errors
|
||||||
|
// are {code,message}. Keep only sanitized diagnostic fields, never
|
||||||
|
// authorization headers, request bodies or raw response bodies.
|
||||||
|
let error = body.get("error").unwrap_or(&body);
|
||||||
|
let closed_before_response = path
|
||||||
|
.strip_suffix("/operations")
|
||||||
|
.filter(|session_path| session_path.starts_with("/v1/workdir-sessions/"))
|
||||||
|
.is_some_and(|session_path| {
|
||||||
|
state
|
||||||
|
.closed
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|closed| closed == session_path)
|
||||||
|
});
|
||||||
|
state.failures.lock().unwrap().push(RuntimeHttpFailure {
|
||||||
|
method,
|
||||||
|
path: path.clone(),
|
||||||
|
status,
|
||||||
|
code: error["code"].as_str().map(str::to_owned),
|
||||||
|
message: error["message"].as_str().map(str::to_owned),
|
||||||
|
closed_before_response,
|
||||||
|
harness_message: body["error"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("unknown fixture error")
|
||||||
|
.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if is_stop && status.is_success() {
|
||||||
|
let response: runtime_api::WorkerLifecycleResponse =
|
||||||
|
serde_json::from_slice(&bytes).unwrap();
|
||||||
|
state.acks.lock().unwrap().push(response.ack);
|
||||||
|
// This is a real completed Runtime stop, not merely proxy admission.
|
||||||
|
state.acked.notify_one();
|
||||||
|
}
|
||||||
|
if is_open && status.is_success() {
|
||||||
|
let response: workdir::http::OpenWorkdirSessionResponse =
|
||||||
|
serde_json::from_slice(&bytes).unwrap();
|
||||||
|
state
|
||||||
|
.opened
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(response.session_id.as_str().to_owned());
|
||||||
|
}
|
||||||
|
if is_close && status == StatusCode::NO_CONTENT {
|
||||||
|
state.closed.lock().unwrap().push(path);
|
||||||
|
}
|
||||||
|
let mut response = Response::new(Body::from(bytes));
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_ids(&self) -> Vec<String> {
|
||||||
|
let mut ids: Vec<_> = self
|
||||||
|
.closed
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|path| {
|
||||||
|
path.strip_prefix("/v1/workdir-sessions/")
|
||||||
|
.unwrap()
|
||||||
|
.to_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StopProxy {
|
||||||
|
transport: Arc<StopTransport>,
|
||||||
|
_server: ConnectedRuntimeServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StopProxy {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// A failed assertion must not strand an admitted Host workflow or the
|
||||||
|
// proxy's graceful shutdown behind the test-only dispatch barrier.
|
||||||
|
self.transport.release.add_permits(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StopProxy {
|
||||||
|
async fn install(harness: &ConnectedWorkerHarness) -> Self {
|
||||||
|
let api = &harness.api;
|
||||||
|
let binding = api
|
||||||
|
.store
|
||||||
|
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, CONNECTED_RUNTIME)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let transport = Arc::new(StopTransport {
|
||||||
|
upstream: binding.base_url.clone(),
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(CLEANUP_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
hold_first_stop: AtomicBool::new(true),
|
||||||
|
dispatched: Notify::new(),
|
||||||
|
release: Semaphore::new(0),
|
||||||
|
acked: Notify::new(),
|
||||||
|
acks: Mutex::new(Vec::new()),
|
||||||
|
opened: Mutex::new(Vec::new()),
|
||||||
|
closed: Mutex::new(Vec::new()),
|
||||||
|
failures: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let server = ConnectedRuntimeServer::start(
|
||||||
|
listener,
|
||||||
|
Router::new()
|
||||||
|
.fallback(axum::routing::any(StopTransport::forward))
|
||||||
|
.with_state(transport.clone()),
|
||||||
|
);
|
||||||
|
// Retain the real trusted binding and authorization issuer. Only the
|
||||||
|
// transport address changes, as in workdir_session_close_tests.
|
||||||
|
api.runtime.register_or_replace(
|
||||||
|
RemoteWorkerRuntime::new(
|
||||||
|
RemoteRuntimeConfig {
|
||||||
|
runtime_id: CONNECTED_RUNTIME.into(),
|
||||||
|
workspace_id: Some(TEST_WORKSPACE_ID.into()),
|
||||||
|
display_name: binding.display_name.clone(),
|
||||||
|
base_url: endpoint,
|
||||||
|
bearer_token: None,
|
||||||
|
workspace_authorization: Some(
|
||||||
|
crate::hosts::WorkspaceRuntimeAuthorization::new(
|
||||||
|
api.store.clone(),
|
||||||
|
api.signing_identities.clone(),
|
||||||
|
harness.backend_url.clone(),
|
||||||
|
binding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
strict_public_egress: false,
|
||||||
|
cached_worker_creation_available: true,
|
||||||
|
cached_os: "linux".into(),
|
||||||
|
cached_arch: "x86_64".into(),
|
||||||
|
cached_status: "active".into(),
|
||||||
|
timeout: CLEANUP_TIMEOUT,
|
||||||
|
},
|
||||||
|
TEST_WORKSPACE_ID.into(),
|
||||||
|
harness.backend_url.clone(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
transport,
|
||||||
|
_server: server,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open(
|
||||||
|
&self,
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
workdir: &str,
|
||||||
|
) -> (WorkdirSessionHandle, String) {
|
||||||
|
let before = self.transport.opened.lock().unwrap().len();
|
||||||
|
let session = api
|
||||||
|
.runtime
|
||||||
|
.open_workdir_session(&worker.runtime_id, workdir, Some(&worker.worker_id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let opened = self.transport.opened.lock().unwrap();
|
||||||
|
assert_eq!(opened.len(), before + 1, "one actual Runtime session open");
|
||||||
|
(session, opened[before].clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The harness owns servers/temporary roots; this guard also stops an active
|
||||||
|
// restored Controller on assertion failure before those servers are dropped.
|
||||||
|
struct StopControllerOnDrop {
|
||||||
|
runtime: worker_runtime::Runtime,
|
||||||
|
worker: worker_runtime::identity::WorkerRef,
|
||||||
|
}
|
||||||
|
impl Drop for StopControllerOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.runtime.stop_worker(
|
||||||
|
&self.worker,
|
||||||
|
Some("cleanup connected stop/restore fixture".into()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_held_command(session: &WorkdirSessionHandle) -> CommandHandle {
|
||||||
|
let command = session
|
||||||
|
.start_command(workdir::CommandRequest {
|
||||||
|
// A long-lived resource, not a timer used to infer progress. Session
|
||||||
|
// close must cancel and reap it; no external service is contacted.
|
||||||
|
command: "printf cleanup-command; exec tail -f /dev/null".into(),
|
||||||
|
timeout_secs: 120,
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
cwd: workdir::WorkdirPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
session.command_status(command.clone()).await.unwrap(),
|
||||||
|
workdir::CommandStatus::Running,
|
||||||
|
"fixture command must really be live rather than a failed shell launch"
|
||||||
|
);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_live(session: &WorkdirSessionHandle) {
|
||||||
|
session
|
||||||
|
.stat(workdir::StatRequest {
|
||||||
|
path: workdir::WorkdirPath::new("README.md").unwrap(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("new Runtime session must still serve the real materialized checkout");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_closed(session: &WorkdirSessionHandle) {
|
||||||
|
session
|
||||||
|
.stat(workdir::StatRequest {
|
||||||
|
path: workdir::WorkdirPath::new("README.md").unwrap(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("observed Runtime session must no longer serve filesystem operations");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn stop_ack_cleanup_preserves_real_restored_sessions_and_rebound_connection() {
|
||||||
|
crate::restore_test_environment::require_restore_test_environment();
|
||||||
|
let (completed, _completion) = tokio::sync::mpsc::channel(4);
|
||||||
|
let harness = ConnectedWorkerHarness::start(ConnectedAuthorizationModel { completed }).await;
|
||||||
|
let proxy = StopProxy::install(&harness).await;
|
||||||
|
let api = &harness.api;
|
||||||
|
let creation_probe_index = proxy.transport.failures.lock().unwrap().len();
|
||||||
|
let (status, created) = harness
|
||||||
|
.human(
|
||||||
|
reqwest::Method::POST,
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"),
|
||||||
|
json!({"runtime_id":CONNECTED_RUNTIME, "repository_key":"test-repository", "selector":"HEAD", "operation_id":"cleanup-connected-workdir"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CREATED, "{created}");
|
||||||
|
let workdir = created["item"]["working_directory_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
{
|
||||||
|
// create_workspace_working_directory probes its reserved Runtime ID
|
||||||
|
// before materializing it. Restrict this expectation to the sole failure
|
||||||
|
// during that completed creation request and to the returned exact ID.
|
||||||
|
// Later misses of the same Workdir must not inherit this exemption.
|
||||||
|
let failures = proxy.transport.failures.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
failures.len(),
|
||||||
|
creation_probe_index + 1,
|
||||||
|
"creation must produce exactly one initial absence probe: {failures:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
failures[creation_probe_index].is_expected_creation_probe(&workdir),
|
||||||
|
"unexpected creation probe response: {failures:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (status, created) = harness
|
||||||
|
.human(
|
||||||
|
reqwest::Method::POST,
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||||
|
json!({
|
||||||
|
"runtime_id":CONNECTED_RUNTIME, "display_name":"Connected stop cleanup",
|
||||||
|
"profile":"builtin:coder", "initial_submit":[],
|
||||||
|
"workdir_attachments":[{"alias":ALIAS,"working_directory_id":workdir}],
|
||||||
|
"feature_connections":{}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{created}");
|
||||||
|
let worker = RuntimeWorkerRef::new(
|
||||||
|
created["runtime_id"].as_str().unwrap(),
|
||||||
|
created["worker_id"].as_str().unwrap(),
|
||||||
|
);
|
||||||
|
let local_worker = worker_runtime::identity::WorkerRef::try_from(&worker).unwrap();
|
||||||
|
let _controller_cleanup = StopControllerOnDrop {
|
||||||
|
runtime: harness.runtime.clone(),
|
||||||
|
worker: local_worker.clone(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"this must remain the ordinary Worker stop/restore path"
|
||||||
|
);
|
||||||
|
let old_link = api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|link| link.unlinked_at.is_none() && link.alias == ALIAS)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Retain two independent pre-stop command sources. An attachment replacement
|
||||||
|
// normally closes the displaced session itself; do not manufacture an orphan
|
||||||
|
// by overwriting one and then demand that stop follow a removed registry row.
|
||||||
|
let (old_first_source, old_first_source_id) = proxy.open(api, &worker, &workdir).await;
|
||||||
|
let (old_command_source, old_command_id) = proxy.open(api, &worker, &workdir).await;
|
||||||
|
let old_first_provider_command = start_held_command(&old_first_source).await;
|
||||||
|
let old_provider_command = start_held_command(&old_command_source).await;
|
||||||
|
let (old_first_command, old_command) = {
|
||||||
|
let _guard = current_worker_session_lock(api, &worker).lock_owned().await;
|
||||||
|
let mut registry = api.workdir_sessions.lock().unwrap();
|
||||||
|
assert!(!registry.attachments.contains_key(&worker));
|
||||||
|
let first = registry.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
ALIAS.into(),
|
||||||
|
old_first_source.clone(),
|
||||||
|
old_first_provider_command,
|
||||||
|
);
|
||||||
|
let second = registry.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
ALIAS.into(),
|
||||||
|
old_command_source.clone(),
|
||||||
|
old_provider_command,
|
||||||
|
);
|
||||||
|
(first, second)
|
||||||
|
};
|
||||||
|
let stop_path = format!(
|
||||||
|
"/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/stop",
|
||||||
|
worker.runtime_id, worker.worker_id
|
||||||
|
);
|
||||||
|
let stop_api = api.clone();
|
||||||
|
let owner = harness.owner.clone();
|
||||||
|
let first_stop_path = stop_path.clone();
|
||||||
|
let stopping = tokio::spawn(async move {
|
||||||
|
response(
|
||||||
|
&stop_api,
|
||||||
|
human_request(
|
||||||
|
&owner,
|
||||||
|
"POST",
|
||||||
|
&first_stop_path,
|
||||||
|
Some(json!({"reason":"first real stop before restore"})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(CLEANUP_TIMEOUT, proxy.transport.dispatched.notified())
|
||||||
|
.await
|
||||||
|
.expect("Backend stop must reach transport after its resource observation");
|
||||||
|
let session_guard = tokio::time::timeout(
|
||||||
|
CLEANUP_TIMEOUT,
|
||||||
|
current_worker_session_lock(api, &worker).lock_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stop dispatch must not retain the pre-ACK session fence");
|
||||||
|
proxy.transport.release.add_permits(1);
|
||||||
|
tokio::time::timeout(CLEANUP_TIMEOUT, proxy.transport.acked.notified())
|
||||||
|
.await
|
||||||
|
.expect("real Runtime must acknowledge stop while Backend cleanup is fenced");
|
||||||
|
{
|
||||||
|
let acks = proxy.transport.acks.lock().unwrap();
|
||||||
|
assert_eq!(acks.len(), 1);
|
||||||
|
assert_eq!(acks[0].status, runtime_api::WorkerStatus::Stopped);
|
||||||
|
assert!(
|
||||||
|
!acks[0].replayed,
|
||||||
|
"must exercise fresh-ACK cleanup, not replay skip"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!stopping.is_finished(),
|
||||||
|
"ACK must not bypass the session fence"
|
||||||
|
);
|
||||||
|
assert!(proxy.transport.closed_ids().is_empty());
|
||||||
|
// Provider sessions are not Controller state: actual Runtime stop alone has
|
||||||
|
// not closed the Backend-observed command sources.
|
||||||
|
assert_live(&old_first_source).await;
|
||||||
|
assert_live(&old_command_source).await;
|
||||||
|
|
||||||
|
let restore_api = api.clone();
|
||||||
|
let restore_worker = worker.clone();
|
||||||
|
let restored = tokio::time::timeout(
|
||||||
|
CLEANUP_TIMEOUT,
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let stopped = restore_api.runtime.worker(&restore_worker).unwrap();
|
||||||
|
assert_eq!(stopped.state, "stopped");
|
||||||
|
restore_api
|
||||||
|
.runtime
|
||||||
|
.restore_worker(
|
||||||
|
&restore_worker,
|
||||||
|
runtime_api::WorkerRestoreRequest {
|
||||||
|
expected_observation_token: stopped.restore_observation_token.unwrap(),
|
||||||
|
request_id: "cleanup-connected-real-restore".into(),
|
||||||
|
preparation: Some(runtime_api::WorkerRestorePreparation::default()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("real restore must not need the cleanup-owned Worker control lane")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restored.state,
|
||||||
|
server_api::WorkerRestoreState::Accepted,
|
||||||
|
"{restored:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
harness.runtime.worker_detail(&local_worker).unwrap().status,
|
||||||
|
worker_runtime::catalog::WorkerStatus::Idle,
|
||||||
|
"the actual Runtime catalog must expose the reconstructed Controller"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The fixture owns the production session fence throughout rebind and
|
||||||
|
// resource publication. Keep worker/alias/workdir/capabilities identical;
|
||||||
|
// only the storage-issued connection lifetime is different.
|
||||||
|
let detached = api
|
||||||
|
.store
|
||||||
|
.detach_worker_workdir_connection(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&worker,
|
||||||
|
ALIAS,
|
||||||
|
&old_link.connection_id,
|
||||||
|
&now_registry_timestamp(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(detached.connection_id, old_link.connection_id);
|
||||||
|
assert!(detached.unlinked_at.is_some());
|
||||||
|
let new_link = api.store.attach_worker_workdir(&old_link).unwrap();
|
||||||
|
assert_ne!(new_link.connection_id, old_link.connection_id);
|
||||||
|
assert_eq!(new_link.worker, old_link.worker);
|
||||||
|
assert_eq!(new_link.alias, old_link.alias);
|
||||||
|
assert_eq!(new_link.workdir_id, old_link.workdir_id);
|
||||||
|
assert_eq!(new_link.capabilities, old_link.capabilities);
|
||||||
|
|
||||||
|
let (new_attachment, new_attachment_id) = proxy.open(api, &worker, &workdir).await;
|
||||||
|
let (new_command_source, new_command_id) = proxy.open(api, &worker, &workdir).await;
|
||||||
|
let new_provider_command = start_held_command(&new_command_source).await;
|
||||||
|
let new_command = {
|
||||||
|
let mut registry = api.workdir_sessions.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.insert_attachment(worker.clone(), new_attachment.clone())
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
registry.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
ALIAS.into(),
|
||||||
|
new_command_source.clone(),
|
||||||
|
new_provider_command.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut record = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
record.display_name = Some("new connection projection must survive old stop".into());
|
||||||
|
api.store.upsert_workdir_registry(&record).unwrap();
|
||||||
|
assert_live(&new_attachment).await;
|
||||||
|
assert_eq!(
|
||||||
|
new_command_source
|
||||||
|
.command_status(new_provider_command.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
workdir::CommandStatus::Running,
|
||||||
|
);
|
||||||
|
assert!(!stopping.is_finished());
|
||||||
|
drop(session_guard);
|
||||||
|
|
||||||
|
let (status, stopped) = tokio::time::timeout(CLEANUP_TIMEOUT, stopping)
|
||||||
|
.await
|
||||||
|
.expect("old cleanup must finish after the session fence is released")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::OK, "{stopped}");
|
||||||
|
assert_eq!(stopped["state"], "accepted", "{stopped}");
|
||||||
|
let mut old_ids = vec![old_first_source_id, old_command_id];
|
||||||
|
old_ids.sort();
|
||||||
|
assert_eq!(
|
||||||
|
proxy.transport.closed_ids(),
|
||||||
|
old_ids,
|
||||||
|
"close only pre-dispatch sessions"
|
||||||
|
);
|
||||||
|
assert_closed(&old_first_source).await;
|
||||||
|
assert_closed(&old_command_source).await;
|
||||||
|
assert_live(&new_attachment).await;
|
||||||
|
assert_live(&new_command_source).await;
|
||||||
|
assert_eq!(
|
||||||
|
new_command_source
|
||||||
|
.command_status(new_provider_command)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
workdir::CommandStatus::Running,
|
||||||
|
"old cleanup must not cancel a post-restore command"
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let registry = api.workdir_sessions.lock().unwrap();
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
registry.attachments.get(&worker).unwrap(),
|
||||||
|
&new_attachment
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.command(&worker, ALIAS, &old_first_command)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(registry.command(&worker, ALIAS, &old_command).is_none());
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
®istry
|
||||||
|
.command(&worker, ALIAS, &new_command)
|
||||||
|
.unwrap()
|
||||||
|
.source,
|
||||||
|
&new_command_source,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.display_name,
|
||||||
|
record.display_name,
|
||||||
|
"old stop must not project a new connection even for identical worker/alias/workdir"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|link| link.unlinked_at.is_none() && link.alias == ALIAS)
|
||||||
|
.unwrap()
|
||||||
|
.connection_id,
|
||||||
|
new_link.connection_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Surviving resources are not leaked: a later explicit, non-replayed stop
|
||||||
|
// observes their own lifetimes and closes their actual Runtime sessions.
|
||||||
|
let (status, stopped) = harness
|
||||||
|
.human(
|
||||||
|
reqwest::Method::POST,
|
||||||
|
&stop_path,
|
||||||
|
json!({"reason":"explicit stop of restored Worker"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{stopped}");
|
||||||
|
assert_eq!(stopped["state"], "accepted", "{stopped}");
|
||||||
|
{
|
||||||
|
let acks = proxy.transport.acks.lock().unwrap();
|
||||||
|
assert_eq!(acks.len(), 2);
|
||||||
|
assert_eq!(acks[1].status, runtime_api::WorkerStatus::Stopped);
|
||||||
|
assert!(!acks[1].replayed);
|
||||||
|
}
|
||||||
|
old_ids.extend([new_attachment_id, new_command_id]);
|
||||||
|
old_ids.sort();
|
||||||
|
assert_eq!(
|
||||||
|
proxy.transport.closed_ids(),
|
||||||
|
old_ids,
|
||||||
|
"each actual session closes once"
|
||||||
|
);
|
||||||
|
assert_closed(&new_attachment).await;
|
||||||
|
assert_closed(&new_command_source).await;
|
||||||
|
{
|
||||||
|
let registry = api.workdir_sessions.lock().unwrap();
|
||||||
|
assert!(!registry.attachments.contains_key(&worker));
|
||||||
|
assert!(registry.command(&worker, ALIAS, &new_command).is_none());
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, &workdir)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.display_name,
|
||||||
|
record.display_name,
|
||||||
|
"later stop must still project its current connection normally"
|
||||||
|
);
|
||||||
|
let runtime_errors = harness.runtime_errors.lock().unwrap().clone();
|
||||||
|
let failures = proxy.transport.failures.lock().unwrap();
|
||||||
|
let unexpected: Vec<_> = failures
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(index, failure)| {
|
||||||
|
*index != creation_probe_index && !failure.is_expected_closed_session()
|
||||||
|
})
|
||||||
|
.map(|(_, failure)| failure)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
unexpected.is_empty(),
|
||||||
|
"unexpected Runtime HTTP errors: {unexpected:?}; runtime_errors={runtime_errors:?}; all HTTP failures={failures:?}"
|
||||||
|
);
|
||||||
|
// Do not silently lose failures from any Runtime request that bypassed our
|
||||||
|
// proxy. Reconcile even the shared harness's lossy flat-envelope messages.
|
||||||
|
// Apart from the single creation probe validated at its request boundary,
|
||||||
|
// a NotFound is expected only for operations on a session whose DELETE was
|
||||||
|
// already confirmed; a 401/403 or any other NotFound remains a failure.
|
||||||
|
let mut captured = runtime_errors.clone();
|
||||||
|
let mut classified: Vec<_> = failures
|
||||||
|
.iter()
|
||||||
|
.map(|failure| failure.harness_message.clone())
|
||||||
|
.collect();
|
||||||
|
captured.sort();
|
||||||
|
classified.sort();
|
||||||
|
assert_eq!(
|
||||||
|
captured, classified,
|
||||||
|
"unclassified Runtime errors: runtime_errors={runtime_errors:?}; HTTP failures={failures:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
//! Connected Subject Body API regressions in the normal Server lib target
|
||||||
|
//! (`subject_body_connected` filter). The authenticated human router, signed
|
||||||
|
//! Backend <-> RuntimeHTTP requests, ProfileRuntimeWorkerFactory/Controller,
|
||||||
|
//! singleton reservations, SQLite and canonical FS Session storage are real.
|
||||||
|
//! Only the external model stream is scripted. One transport fault discards an
|
||||||
|
//! already-produced stop response; it does not replace stop execution or receipts.
|
||||||
|
//! Existing Memory is fixture input through the domain store; Journal admission
|
||||||
|
//! uses real, authorized canonical Session capture, not invented source identity.
|
||||||
|
//!
|
||||||
|
//! Restore requires the runner-owned isolation checked by
|
||||||
|
//! `tests/support/restore_environment.rs` before any fixture starts. This module
|
||||||
|
//! never sets process environment, installs tools, or contacts a live model.
|
||||||
|
//! Resource races and Workdir rebinding belong to the separate lifecycle suite.
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const DEADLINE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BodyModel(tokio::sync::mpsc::Sender<String>);
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl agen::llm_client::LlmClient for BodyModel {
|
||||||
|
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
request: agen::llm_client::Request,
|
||||||
|
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||||
|
{
|
||||||
|
use agen::llm_client::event::{Event, StopReason};
|
||||||
|
for name in [
|
||||||
|
"ConversationRead",
|
||||||
|
"ConversationSend",
|
||||||
|
"SubjektivMemoryQuery",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
request.tools.iter().any(|tool| tool.name == name),
|
||||||
|
"the unchanged Subject conversation profile must expose {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.0.send(format!("{:?}", request.items)).await.unwrap();
|
||||||
|
Ok(Box::pin(futures::stream::iter([
|
||||||
|
Ok(Event::text_block_start(0)),
|
||||||
|
Ok(Event::text_delta(0, "Retained internal body turn")),
|
||||||
|
Ok(Event::text_block_stop(0, Some(StopReason::EndTurn))),
|
||||||
|
])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectedBody {
|
||||||
|
connected: ConnectedWorkerHarness,
|
||||||
|
lose_stop_ack: Arc<AtomicBool>,
|
||||||
|
inputs: tokio::sync::mpsc::Receiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectedBody {
|
||||||
|
async fn new() -> Self {
|
||||||
|
// Fail before starting either server or touching restore allocation paths.
|
||||||
|
crate::restore_test_environment::require_restore_test_environment();
|
||||||
|
let (tx, inputs) = tokio::sync::mpsc::channel(8);
|
||||||
|
let lose_stop_ack = Arc::new(AtomicBool::new(false));
|
||||||
|
let fault = lose_stop_ack.clone();
|
||||||
|
let connected = ConnectedWorkerHarness::start_with_layer(BodyModel(tx), move |router| {
|
||||||
|
router.layer(axum::middleware::from_fn(
|
||||||
|
move |request: axum::extract::Request, next: axum::middleware::Next| {
|
||||||
|
let fault = fault.clone();
|
||||||
|
async move {
|
||||||
|
let discard = request.uri().path().ends_with("/body/stop")
|
||||||
|
&& fault.swap(false, Ordering::SeqCst);
|
||||||
|
let result = next.run(request).await;
|
||||||
|
if discard {
|
||||||
|
assert!(
|
||||||
|
result.status().is_success(),
|
||||||
|
"stop must execute before ACK loss"
|
||||||
|
);
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::BAD_GATEWAY)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({"message":"injected lost stop response"}).to_string(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Body start intentionally chooses a Runtime itself and prefers embedded.
|
||||||
|
// Remove only the unused deterministic fixture Runtime via its real idle
|
||||||
|
// removal contract; never route around signed HTTP or replace execution.
|
||||||
|
assert!(matches!(
|
||||||
|
connected
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.unregister_if_idle(EMBEDDED_WORKER_RUNTIME_ID, 100)
|
||||||
|
.unwrap(),
|
||||||
|
crate::hosts::RuntimeRegistryUnregisterResult::Removed
|
||||||
|
));
|
||||||
|
let available = connected.api.runtime.list_runtimes(100);
|
||||||
|
assert_eq!(available.items.len(), 1);
|
||||||
|
assert_eq!(available.items[0].runtime_id, CONNECTED_RUNTIME);
|
||||||
|
assert!(available.items[0].worker_creation_available);
|
||||||
|
Self {
|
||||||
|
connected,
|
||||||
|
lose_stop_ack,
|
||||||
|
inputs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(&self, method: &str, path: &str, body: Value) -> (StatusCode, Value) {
|
||||||
|
self.connected
|
||||||
|
.human(method.parse().unwrap(), path, body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ok(&self, method: &str, path: &str, body: Value) -> Value {
|
||||||
|
let (status, result) = self.request(method, path, body).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{method} {path}: {result}");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subject(&self, operation: &str) -> String {
|
||||||
|
let (status, created) = self
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
|
json!({"operation_id":operation,"role":"connected-body","behavior_md":""}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CREATED,
|
||||||
|
"normal Subject creation: {created}"
|
||||||
|
);
|
||||||
|
created["id"].as_str().unwrap().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(&self, subject: &str) -> Value {
|
||||||
|
self.ok("GET", &body_path(subject), Value::Null).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mutate(&self, subject: &str, suffix: &str, request: Value) -> Value {
|
||||||
|
self.ok("POST", &format!("{}{suffix}", body_path(subject)), request)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self, subject: &str, request_id: &str) -> Value {
|
||||||
|
let current = self.read(subject).await;
|
||||||
|
let request = intent(request_id, ¤t);
|
||||||
|
let stopped = self.mutate(subject, "/stop", request.clone()).await;
|
||||||
|
assert_eq!(stopped["disposition"], "accepted", "{stopped}");
|
||||||
|
let outcome = self.mutate(subject, "/stop/outcome", request.clone()).await;
|
||||||
|
assert_eq!(outcome["outcome"], "stopped", "{outcome}");
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_count(&self) -> usize {
|
||||||
|
self.connected
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.list_workers_for_runtime(CONNECTED_RUNTIME, 100)
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_path(subject: &str) -> String {
|
||||||
|
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/body")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absent_start(request_id: &str) -> Value {
|
||||||
|
json!({"request_id":request_id,"expected_current":null,"expected_observation_token":null})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intent(request_id: &str, body: &Value) -> Value {
|
||||||
|
let token = body["observation"]["worker"]["restore_observation_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("available body must expose the Runtime observation fence: {body}")
|
||||||
|
});
|
||||||
|
json!({"request_id":request_id,"expected_current":body["current"],"expected_observation_token":token})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn body_idle(
|
||||||
|
events: &mut tokio::sync::broadcast::Receiver<
|
||||||
|
worker_runtime::observation::WorkerObservationEvent,
|
||||||
|
>,
|
||||||
|
worker: &str,
|
||||||
|
) {
|
||||||
|
tokio::time::timeout(DEADLINE, async {
|
||||||
|
loop {
|
||||||
|
let event = events.recv().await.expect("real Runtime observation");
|
||||||
|
if event.worker_ref.worker_id.to_string() != worker {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match event.payload {
|
||||||
|
protocol::Event::Error { code, message } => {
|
||||||
|
panic!("body turn failed: {code:?}: {message}")
|
||||||
|
}
|
||||||
|
protocol::Event::WorkerState { snapshot }
|
||||||
|
if snapshot.state == protocol::WorkerState::Idle =>
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("Controller must publish Idle after the scripted model turn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn body_same_request_concurrent_start_stop_outcome_and_restore_preserve_subject_data() {
|
||||||
|
let mut f = ConnectedBody::new().await;
|
||||||
|
let subject = f.subject("body-new-subject").await;
|
||||||
|
let store = open_subjektiv_store(&f.connected.api).unwrap();
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
initial.execution,
|
||||||
|
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &subject)
|
||||||
|
);
|
||||||
|
assert!(initial.delegation.is_empty());
|
||||||
|
assert!(f.read(&subject).await["current"].is_null());
|
||||||
|
|
||||||
|
// Retention inputs are deliberately independent of Worker execution and of
|
||||||
|
// Ticket conclusion. Direct Memory setup is not a claimed body mutation.
|
||||||
|
let memory = store
|
||||||
|
.create_memory(
|
||||||
|
&subject,
|
||||||
|
crate::subjektiv::MemoryDraft::active(
|
||||||
|
memory::extract::CandidateKind::Decision,
|
||||||
|
"Keep this decision",
|
||||||
|
"Retained Memory body",
|
||||||
|
"Survive body stop/restore",
|
||||||
|
"retention fixture",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tickets = browser_ticket_backend(&f.connected.api).unwrap();
|
||||||
|
let ticket = tickets
|
||||||
|
.create(NewTicket::new("Body stop does not conclude this case"))
|
||||||
|
.unwrap();
|
||||||
|
let before_case = tickets.show(ticket.id.clone().into()).unwrap();
|
||||||
|
let conversations = format!("/api/w/{TEST_WORKSPACE_ID}/chat/conversations");
|
||||||
|
let conversation = f
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&conversations,
|
||||||
|
json!({"operation_id":"body-conversation","subject_id":subject}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let messages = format!(
|
||||||
|
"{}/{}/messages",
|
||||||
|
conversations,
|
||||||
|
conversation["id"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
let saved = f
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&messages,
|
||||||
|
json!({"operation_id":"body-before-start","text":"BEFORE_BODY_START_RETAINED"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_ne!(saved["delivery"]["state"], "accepted");
|
||||||
|
|
||||||
|
let start = absent_start("body-concurrent-same");
|
||||||
|
let path = format!("{}/start", body_path(&subject));
|
||||||
|
let (left, right) = tokio::join!(
|
||||||
|
f.request("POST", &path, start.clone()),
|
||||||
|
f.request("POST", &path, start.clone())
|
||||||
|
);
|
||||||
|
for (status, result) in [&left, &right] {
|
||||||
|
assert_eq!(*status, StatusCode::OK, "exact concurrent retry: {result}");
|
||||||
|
assert!(
|
||||||
|
matches!(result["disposition"].as_str(), Some("accepted" | "unknown")),
|
||||||
|
"{result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// An in-flight response is not assumed to settle the other call. Reconcile
|
||||||
|
// with the same request identity, never with a second creation request.
|
||||||
|
let replay = f.mutate(&subject, "/start", start.clone()).await;
|
||||||
|
assert_eq!(replay["disposition"], "accepted", "{replay}");
|
||||||
|
let running = f.read(&subject).await;
|
||||||
|
assert_eq!(running["current"]["runtime_id"], CONNECTED_RUNTIME);
|
||||||
|
assert_eq!(running["observation"]["kind"], "available");
|
||||||
|
assert_eq!(
|
||||||
|
running["observation"]["worker"]["profile"],
|
||||||
|
server_api::authorization::SUBJECT_CONVERSATION_PROFILE
|
||||||
|
);
|
||||||
|
assert_eq!(f.worker_count(), 1);
|
||||||
|
let current = running["current"].clone();
|
||||||
|
let worker = current["worker_id"].as_str().unwrap().to_owned();
|
||||||
|
let source = RuntimeWorkerRef::new(CONNECTED_RUNTIME, &worker);
|
||||||
|
let singleton = f
|
||||||
|
.connected
|
||||||
|
.api
|
||||||
|
.store
|
||||||
|
.require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source)
|
||||||
|
.unwrap()
|
||||||
|
.expect("normal body singleton owner");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(subject_body::reference(&singleton)).unwrap(),
|
||||||
|
current
|
||||||
|
);
|
||||||
|
let live_reuse = f
|
||||||
|
.mutate(&subject, "/start", intent("body-live-reuse", &running))
|
||||||
|
.await;
|
||||||
|
assert_eq!(live_reuse["disposition"], "accepted", "{live_reuse}");
|
||||||
|
assert_eq!(f.worker_count(), 1);
|
||||||
|
|
||||||
|
let mut events = f.connected.runtime.subscribe_worker_observation().unwrap();
|
||||||
|
let delivered = f
|
||||||
|
.ok(
|
||||||
|
"POST",
|
||||||
|
&messages,
|
||||||
|
json!({"operation_id":"body-live-message","text":"BODY_CANONICAL_SESSION_MARKER"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(delivered["delivery"]["state"], "accepted");
|
||||||
|
let input = tokio::time::timeout(DEADLINE, f.inputs.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(input.contains("BODY_CANONICAL_SESSION_MARKER"), "{input}");
|
||||||
|
body_idle(&mut events, &worker).await;
|
||||||
|
let root = f.connected.runtime_root.join("workers").join(&worker);
|
||||||
|
let history = session_store::read_retained_session_snapshot(
|
||||||
|
&root,
|
||||||
|
&format!("worker-runtime-{worker}"),
|
||||||
|
2 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
serde_json::to_string(&history.snapshot)
|
||||||
|
.unwrap()
|
||||||
|
.contains("BODY_CANONICAL_SESSION_MARKER")
|
||||||
|
);
|
||||||
|
let attribution = store
|
||||||
|
.subject_session_attributions(&subject)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.worker_id == worker)
|
||||||
|
.expect("signed startup registered real Session");
|
||||||
|
assert_eq!(attribution.session_id, history.identity.session_id);
|
||||||
|
let host =
|
||||||
|
subjektiv::journal::JournalHostContext::validated_session(&store, attribution.clone())
|
||||||
|
.unwrap();
|
||||||
|
let capture =
|
||||||
|
subjektiv_journal::capture_input(&f.connected.api, &attribution, 0, 0, 128 * 1024).unwrap();
|
||||||
|
let ranges = subjektiv::journal::recording::capture_ranges(&capture).unwrap();
|
||||||
|
assert!(!ranges.is_empty(), "admit actual canonical Session bytes");
|
||||||
|
let journal = store
|
||||||
|
.accept_journal_sources(&host, "Retained body Journal", &ranges)
|
||||||
|
.unwrap();
|
||||||
|
let reader =
|
||||||
|
subjektiv::journal::JournalHostContext::validated_reader(&store, &subject).unwrap();
|
||||||
|
let before_journal =
|
||||||
|
serde_json::to_value(store.read_journal(&reader, &journal).unwrap()).unwrap();
|
||||||
|
let before_sources = serde_json::to_value(
|
||||||
|
store
|
||||||
|
.list_journal_sources(
|
||||||
|
&reader,
|
||||||
|
&journal,
|
||||||
|
None,
|
||||||
|
subjektiv::journal::MAX_JOURNAL_PAGE,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
before_sources["next"].is_null(),
|
||||||
|
"the small retention fixture must compare its complete Journal source ledger: {before_sources}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
before_sources["items"].as_array().unwrap().len(),
|
||||||
|
ranges.len(),
|
||||||
|
"every admitted canonical source must be included in the retention comparison"
|
||||||
|
);
|
||||||
|
let before_messages = f.ok("GET", &messages, Value::Null).await;
|
||||||
|
|
||||||
|
let observed = f.read(&subject).await;
|
||||||
|
let stop = intent("body-stop-lost-ack", &observed);
|
||||||
|
f.lose_stop_ack.store(true, Ordering::SeqCst);
|
||||||
|
let (status, lost) = f
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("{}/stop", body_path(&subject)),
|
||||||
|
stop.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_GATEWAY, "{lost}");
|
||||||
|
// The caller has an unknown result. A GET is only current observation: it
|
||||||
|
// cannot prove which request stopped it, nor manufacture a receipt.
|
||||||
|
let stopped = f.read(&subject).await;
|
||||||
|
assert_eq!(stopped["current"], current);
|
||||||
|
assert_eq!(stopped["observation"]["worker"]["state"], "stopped");
|
||||||
|
let mut unrelated = stop.clone();
|
||||||
|
unrelated["request_id"] = json!("body-stop-never-sent");
|
||||||
|
let missing = f.mutate(&subject, "/stop/outcome", unrelated).await;
|
||||||
|
assert_eq!(
|
||||||
|
missing["outcome"], "not_found",
|
||||||
|
"GET must not invent stop acceptance: {missing}"
|
||||||
|
);
|
||||||
|
let outcome = f.mutate(&subject, "/stop/outcome", stop.clone()).await;
|
||||||
|
assert_eq!(
|
||||||
|
outcome["outcome"], "stopped",
|
||||||
|
"only exact outcome resolves the lost ACK: {outcome}"
|
||||||
|
);
|
||||||
|
let restore = intent("body-restore-same-generation", &stopped);
|
||||||
|
let restored = f.mutate(&subject, "/start", restore.clone()).await;
|
||||||
|
assert_eq!(
|
||||||
|
restored["disposition"],
|
||||||
|
"accepted",
|
||||||
|
"{restored}; Runtime errors: {:?}",
|
||||||
|
f.connected.runtime_errors.lock().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(restored["body"]["current"], current);
|
||||||
|
assert_ne!(
|
||||||
|
restored["body"]["observation"]["worker"]["state"],
|
||||||
|
"stopped"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.worker_count(),
|
||||||
|
1,
|
||||||
|
"restore must not create a replacement Worker/generation"
|
||||||
|
);
|
||||||
|
let replay_restore = f.mutate(&subject, "/start", restore).await;
|
||||||
|
assert_eq!(
|
||||||
|
replay_restore["disposition"], "accepted",
|
||||||
|
"{replay_restore}"
|
||||||
|
);
|
||||||
|
let historical = f.mutate(&subject, "/stop/outcome", stop.clone()).await;
|
||||||
|
assert_eq!(historical["outcome"], "stopped");
|
||||||
|
assert_ne!(
|
||||||
|
historical["body"]["observation"]["worker"]["state"], "stopped",
|
||||||
|
"historical stop receipt is not the restored body's current state"
|
||||||
|
);
|
||||||
|
let stop_replay = f.mutate(&subject, "/stop", stop).await;
|
||||||
|
assert_eq!(stop_replay["disposition"], "accepted");
|
||||||
|
assert_ne!(
|
||||||
|
f.read(&subject).await["observation"]["worker"]["state"],
|
||||||
|
"stopped",
|
||||||
|
"exact historical stop replay must not stop the restored lifetime"
|
||||||
|
);
|
||||||
|
for suffix in ["/start", "/stop"] {
|
||||||
|
let stale = intent("body-old-observation", &observed);
|
||||||
|
let (status, result) = f
|
||||||
|
.request("POST", &format!("{}{suffix}", body_path(&subject)), stale)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"old Runtime observation: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen authority rather than treating an earlier model callback as durable evidence.
|
||||||
|
let reopened = open_subjektiv_store(&f.connected.api).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reopened.subject_permissions(&subject).unwrap(),
|
||||||
|
Some(initial)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(reopened.memory(&subject, &memory.id).unwrap().unwrap()).unwrap(),
|
||||||
|
serde_json::to_value(&memory).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(reopened.read_journal(&reader, &journal).unwrap()).unwrap(),
|
||||||
|
before_journal
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
reopened
|
||||||
|
.list_journal_sources(
|
||||||
|
&reader,
|
||||||
|
&journal,
|
||||||
|
None,
|
||||||
|
subjektiv::journal::MAX_JOURNAL_PAGE
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
before_sources
|
||||||
|
);
|
||||||
|
assert_eq!(f.ok("GET", &messages, Value::Null).await, before_messages);
|
||||||
|
let after_case = tickets.show(ticket.id.into()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after_case, before_case,
|
||||||
|
"body lifecycle must not conclude or edit the case"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reopened.subject(&subject).unwrap().unwrap().state,
|
||||||
|
crate::subjektiv::SubjectState::Active
|
||||||
|
);
|
||||||
|
let after_history = session_store::read_retained_session_snapshot(
|
||||||
|
&root,
|
||||||
|
&format!("worker-runtime-{worker}"),
|
||||||
|
2 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after_history.identity.session_id,
|
||||||
|
history.identity.session_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::to_string(&after_history.snapshot)
|
||||||
|
.unwrap()
|
||||||
|
.contains("BODY_CANONICAL_SESSION_MARKER")
|
||||||
|
);
|
||||||
|
f.stop(&subject, "body-final-stop").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn body_distinct_concurrent_starts_keep_one_owner_and_reject_foreign_targets() {
|
||||||
|
let f = ConnectedBody::new().await;
|
||||||
|
let subject = f.subject("body-distinct-subject").await;
|
||||||
|
let other = f.subject("body-foreign-subject").await;
|
||||||
|
let path = format!("{}/start", body_path(&subject));
|
||||||
|
let (a, b) = tokio::join!(
|
||||||
|
f.request("POST", &path, absent_start("body-click-a")),
|
||||||
|
f.request("POST", &path, absent_start("body-click-b"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
[&a, &b]
|
||||||
|
.iter()
|
||||||
|
.any(|(status, body)| *status == StatusCode::OK && body["disposition"] == "accepted"),
|
||||||
|
"one request must start the body: {a:?}, {b:?}"
|
||||||
|
);
|
||||||
|
for (status, body) in [&a, &b] {
|
||||||
|
assert!(
|
||||||
|
matches!(*status, StatusCode::OK | StatusCode::CONFLICT),
|
||||||
|
"{status}: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(f.worker_count(), 1);
|
||||||
|
let current = f.read(&subject).await;
|
||||||
|
let foreign = intent("body-foreign-intent", ¤t);
|
||||||
|
for suffix in ["/start", "/stop", "/stop/outcome"] {
|
||||||
|
let (status, result) = f
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("{}{suffix}", body_path(&other)),
|
||||||
|
foreign.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"cross-Subject {suffix}: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut old_generation = foreign.clone();
|
||||||
|
old_generation["expected_current"]["generation"] = json!(
|
||||||
|
(current["current"]["generation"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap()
|
||||||
|
+ 1)
|
||||||
|
.to_string()
|
||||||
|
);
|
||||||
|
for suffix in ["/start", "/stop", "/stop/outcome"] {
|
||||||
|
let (status, result) = f
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("{}{suffix}", body_path(&subject)),
|
||||||
|
old_generation.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"stale generation {suffix}: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let reader = seed_test_api_token(f.connected.api.store.as_ref(), "connected-body-reader");
|
||||||
|
for (method, suffix, body) in [
|
||||||
|
("GET", "", None),
|
||||||
|
("POST", "/start", Some(foreign.clone())),
|
||||||
|
("POST", "/stop", Some(foreign.clone())),
|
||||||
|
("POST", "/stop/outcome", Some(foreign.clone())),
|
||||||
|
] {
|
||||||
|
let (status, result) = response(
|
||||||
|
&f.connected.api,
|
||||||
|
human_request(
|
||||||
|
&reader,
|
||||||
|
method,
|
||||||
|
&format!("{}{suffix}", body_path(&subject)),
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"non-owner {suffix}: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let wrong_workspace = body_path(&subject).replace(TEST_WORKSPACE_ID, "not-this-workspace");
|
||||||
|
let (status, result) = f
|
||||||
|
.request("POST", &format!("{wrong_workspace}/stop"), foreign)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND, "{result}");
|
||||||
|
assert_eq!(f.read(&subject).await["current"], current["current"]);
|
||||||
|
assert!(f.read(&other).await["current"].is_null());
|
||||||
|
assert_eq!(f.worker_count(), 1);
|
||||||
|
f.stop(&subject, "body-distinct-cleanup").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn body_start_and_restore_do_not_repair_missing_session_record_permission() {
|
||||||
|
let f = ConnectedBody::new().await;
|
||||||
|
let subject = f.subject("body-revoked-subject").await;
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions");
|
||||||
|
let initial = open_subjektiv_store(&f.connected.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let execution = initial
|
||||||
|
.execution
|
||||||
|
.iter()
|
||||||
|
.filter(|permission| permission.operation != "session.record")
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let revoked = f.ok("POST", &path, json!({"operation_id":"body-remove-session-record",
|
||||||
|
"expected_change_id":initial.change_id,"execution":execution,"delegation":initial.delegation})).await;
|
||||||
|
let (status, denied) = f
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("{}/start", body_path(&subject)),
|
||||||
|
absent_start("body-denied-start"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
status == StatusCode::FORBIDDEN
|
||||||
|
|| (status == StatusCode::OK && denied["disposition"] != "accepted"),
|
||||||
|
"required startup authorization must refuse: {status}: {denied}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
denied.to_string().contains("session.record"),
|
||||||
|
"actionable missing-operation diagnostic: {denied}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.ok("GET", &path, Value::Null).await["permissions"],
|
||||||
|
revoked["permissions"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
open_subjektiv_store(&f.connected.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_session_attributions(&subject)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty(),
|
||||||
|
"refused startup must not register a protected Session"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A separate Subject proves the same denial at restore, with normal defaults
|
||||||
|
// at creation and explicit owner revocation only after a successful start.
|
||||||
|
let subject = f.subject("body-restore-revoked-subject").await;
|
||||||
|
let started = f
|
||||||
|
.mutate(&subject, "/start", absent_start("body-authorized-start"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(started["disposition"], "accepted", "{started}");
|
||||||
|
f.stop(&subject, "body-before-revoked-restore").await;
|
||||||
|
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions");
|
||||||
|
let policy = open_subjektiv_store(&f.connected.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let execution = policy
|
||||||
|
.execution
|
||||||
|
.iter()
|
||||||
|
.filter(|permission| permission.operation != "session.record")
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let revoked = f.ok("POST", &path, json!({"operation_id":"body-revoke-before-restore",
|
||||||
|
"expected_change_id":policy.change_id,"execution":execution,"delegation":policy.delegation})).await;
|
||||||
|
let observed = f.read(&subject).await;
|
||||||
|
let request = intent("body-denied-restore", &observed);
|
||||||
|
let denied = f.mutate(&subject, "/start", request.clone()).await;
|
||||||
|
assert_ne!(denied["disposition"], "accepted", "{denied}");
|
||||||
|
let replay = f.mutate(&subject, "/start", request).await;
|
||||||
|
assert_ne!(replay["disposition"], "accepted", "{replay}");
|
||||||
|
assert_eq!(
|
||||||
|
f.ok("GET", &path, Value::Null).await["permissions"],
|
||||||
|
revoked["permissions"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.read(&subject).await["current"],
|
||||||
|
observed["current"],
|
||||||
|
"denial must not replace singleton generation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.read(&subject).await["observation"]["worker"]["state"],
|
||||||
|
"stopped"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
//! 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 permissions = open_subjektiv_store(&f.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&f.subject)
|
||||||
|
.unwrap();
|
||||||
|
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_eq!(
|
||||||
|
open_subjektiv_store(&f.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&f.subject)
|
||||||
|
.unwrap(),
|
||||||
|
permissions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let deletion = open_subjektiv_store(&f.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_deletion(&f.subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deletion.operation_id, "delete-body-fixture");
|
||||||
|
assert!(!deletion.completed);
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -822,6 +822,7 @@ async fn old_job_removal_reconciles_exact_input_then_erases_copies_across_http_r
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("Prior independent removal".into()),
|
reason: Some("Prior independent removal".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1136,6 +1137,7 @@ async fn old_body_archive_reconciles_unchanged_and_remains_retained_after_subjec
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("Earlier removal".into()),
|
reason: Some("Earlier removal".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,59 @@
|
|||||||
use super::*;
|
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")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() {
|
async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -858,6 +858,7 @@ impl RuntimeFixture {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&self.source,
|
&self.source,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("settle original source before permanent erase".into()),
|
reason: Some("settle original source before permanent erase".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1488,6 +1489,7 @@ async fn stopped_source_pin_releases_after_saved_completion_and_removal() {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&f.source,
|
&f.source,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("archive original".into()),
|
reason: Some("archive original".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1763,6 +1765,7 @@ async fn stopped_partial_journal_recovery_fences_old_attempt_and_resumes_same_im
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
worker,
|
worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("definitive stop before checkpoint".into()),
|
reason: Some("definitive stop before checkpoint".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -2130,6 +2133,7 @@ impl SubjectRemovalFixture {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&target,
|
&target,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("test stopped Subject removal".into()),
|
reason: Some("test stopped Subject removal".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1822,6 +1822,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&coder,
|
&coder,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -277,6 +277,29 @@ impl Fixture {
|
|||||||
RuntimeWorkerRef::new(&self.runtime_id, "registry-close-owner")
|
RuntimeWorkerRef::new(&self.runtime_id, "registry-close-owner")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_worker(&self) -> RuntimeWorkerRef {
|
||||||
|
self.source.publish_profile("builtin:companion");
|
||||||
|
let Json(created) = create_workspace_worker(
|
||||||
|
State(self.api().clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(CreateWorkspaceWorkerRequest {
|
||||||
|
runtime_id: self.runtime_id.clone(),
|
||||||
|
display_name: "session-close Worker".into(),
|
||||||
|
singleton_key: None,
|
||||||
|
profile: Some("builtin:companion".into()),
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_submit: Vec::new(),
|
||||||
|
workdir_attachments: Vec::new(),
|
||||||
|
feature_connections: Default::default(),
|
||||||
|
control_operation_id: None,
|
||||||
|
execution_grant: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn open(&self) -> WorkdirSessionHandle {
|
async fn open(&self) -> WorkdirSessionHandle {
|
||||||
self.api()
|
self.api()
|
||||||
.runtime
|
.runtime
|
||||||
@@ -652,34 +675,150 @@ async fn attachment_close_cancellation_restores_remote_session_for_retry() {
|
|||||||
cancellation_restoration(ClosePath::Attachment).await;
|
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(
|
||||||
|
®istry
|
||||||
|
.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.create_worker().await;
|
||||||
|
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 = Some("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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runtime_lease() {
|
async fn worker_removal_close_failure_retains_attachment_and_retry_releases_runtime_lease() {
|
||||||
let fixture = Fixture::new().await;
|
let fixture = Fixture::new().await;
|
||||||
let api = fixture.api();
|
let api = fixture.api();
|
||||||
fixture.source.publish_profile("builtin:companion");
|
let worker = fixture.create_worker().await;
|
||||||
let Json(created) = create_workspace_worker(
|
|
||||||
State(api.clone()),
|
|
||||||
HeaderMap::new(),
|
|
||||||
Json(CreateWorkspaceWorkerRequest {
|
|
||||||
runtime_id: fixture.runtime_id.clone(),
|
|
||||||
display_name: "session-close removal".into(),
|
|
||||||
singleton_key: None,
|
|
||||||
profile: Some("builtin:companion".into()),
|
|
||||||
ticket_assignment: None,
|
|
||||||
initial_submit: Vec::new(),
|
|
||||||
workdir_attachments: Vec::new(),
|
|
||||||
feature_connections: Default::default(),
|
|
||||||
control_operation_id: None,
|
|
||||||
execution_grant: None,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
|
|
||||||
api.runtime
|
api.runtime
|
||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("prepare removal".into()),
|
reason: Some("prepare removal".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -714,6 +714,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
|
|||||||
.stop(
|
.stop(
|
||||||
&WorkerOperationContext::Backend,
|
&WorkerOperationContext::Backend,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("Drive lifetime test".into()),
|
reason: Some("Drive lifetime test".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -816,6 +817,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
target,
|
target,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ fn operation_input(kind: WorkerInputKind) -> WorkerInputRequest {
|
|||||||
|
|
||||||
fn operation_lifecycle() -> WorkerLifecycleRequest {
|
fn operation_lifecycle() -> WorkerLifecycleRequest {
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("operation-boundary-test".into()),
|
reason: Some("operation-boundary-test".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
}
|
}
|
||||||
@@ -718,6 +719,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
let lifecycle = || server_api::RuntimeWorkerLifecycleRequest {
|
let lifecycle = || server_api::RuntimeWorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ async fn actual_workspace_multiplexer_stalled_writer_timeout_or_disconnect_recla
|
|||||||
.stop(
|
.stop(
|
||||||
&WorkerOperationContext::Backend,
|
&WorkerOperationContext::Backend,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: Some("cleanup-after-multiplexer-exit".into()),
|
reason: Some("cleanup-after-multiplexer-exit".into()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,42 @@ pub(crate) enum WorkerOperationContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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) {
|
fn bind_input(&self, request: &mut WorkerInputRequest) {
|
||||||
// Public adapters cannot choose provenance or consume another input path's
|
// Public adapters cannot choose provenance or consume another input path's
|
||||||
// receipt IDs. Bind these only after the current caller is authorized.
|
// 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)
|
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(
|
pub(super) async fn stop(
|
||||||
&self,
|
&self,
|
||||||
context: &WorkerOperationContext,
|
context: &WorkerOperationContext,
|
||||||
request: WorkerLifecycleRequest,
|
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> {
|
) -> ApiResult<WorkerLifecycleResult> {
|
||||||
let guard = self.authorize_operation(context, "stop").await?;
|
let guard = self.authorize_operation(context, "stop").await?;
|
||||||
|
context.bind_stop_intent(self.api.workspace_id(), &mut request)?;
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
// Once admitted, Runtime stop, session close and linked Workdir
|
// Once admitted, Runtime stop, session close and linked Workdir
|
||||||
// publication have one owner. Never hand an accepted stop's guards
|
// publication have one owner. Never hand an accepted stop's guards
|
||||||
// back to its cancellable waiter or acquire this Host lane again.
|
// back to its cancellable waiter or acquire this Host lane again.
|
||||||
run_host_workflow(self.api.host_executors.host()?, async move {
|
run_host_workflow(self.api.host_executors.host()?, async move {
|
||||||
let _control = guard;
|
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
|
let result = worker
|
||||||
.api
|
.api
|
||||||
.runtime
|
.runtime
|
||||||
.stop_worker(&worker.identity, request)
|
.stop_worker(&worker.identity, request)
|
||||||
.map_err(|error| error.into_error())?;
|
.map_err(RuntimeRegistryError::into_error)?;
|
||||||
if result.state != InternalWorkerOperationState::Accepted {
|
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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
parse_runtime_worker_id_for_registry(&worker.identity.worker_id)?;
|
parse_runtime_worker_id_for_registry(&worker.identity.worker_id)?;
|
||||||
let _session = current_worker_session_lock(&worker.api, &worker.identity)
|
let _session = current_worker_session_lock(&worker.api, &worker.identity)
|
||||||
.lock_owned()
|
.lock_owned()
|
||||||
.await;
|
.await;
|
||||||
close_current_worker_session_locked(&worker.api, &worker.identity).await?;
|
close_observed_worker_workdir_sessions(
|
||||||
if let Some(record) = worker
|
&worker.api.workdir_sessions,
|
||||||
.api
|
&observed_sessions,
|
||||||
.store
|
)
|
||||||
.get_worker_registry(worker.api.workspace_id(), &worker.identity)?
|
.await
|
||||||
{
|
.map_err(|message| Error::RuntimeOperationFailed {
|
||||||
sync_linked_workdir_after_worker_stop(
|
runtime_id: worker.identity.runtime_id.clone(),
|
||||||
&worker.api,
|
code: "workdir_session_close_failed".into(),
|
||||||
&worker.identity.runtime_id,
|
message,
|
||||||
&record,
|
})?;
|
||||||
)?;
|
sync_linked_workdir_after_worker_stop(&worker.api, &worker.identity, &observed_links)?;
|
||||||
}
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -489,6 +662,9 @@ impl WorkspaceWorker {
|
|||||||
context: &WorkerOperationContext,
|
context: &WorkerOperationContext,
|
||||||
request: WorkerLifecycleRequest,
|
request: WorkerLifecycleRequest,
|
||||||
) -> ApiResult<WorkerLifecycleResult> {
|
) -> 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 guard = self.authorize_operation(context, "cancel").await?;
|
||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
self.api
|
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]
|
#[test]
|
||||||
fn generic_input_binds_caller_source_and_isolates_client_ids_from_chat_and_other_paths() {
|
fn generic_input_binds_caller_source_and_isolates_client_ids_from_chat_and_other_paths() {
|
||||||
let browser = |id: &str| WorkerOperationContext::Browser {
|
let browser = |id: &str| WorkerOperationContext::Browser {
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -479,6 +480,7 @@ async fn orchestrator_attention_restore_keeps_pending_unknown_delivery_and_id()
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1023,6 +1025,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1131,6 +1134,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
@@ -1151,6 +1155,7 @@ async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledg
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ fn delivery_stop(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
|||||||
.stop_worker(
|
.stop_worker(
|
||||||
worker,
|
worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ mod plugin_controller_tests {
|
|||||||
api.runtime.stop_worker(
|
api.runtime.stop_worker(
|
||||||
&worker,
|
&worker,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ mod plugin_remote_controller_tests {
|
|||||||
api.runtime.stop_worker(
|
api.runtime.stop_worker(
|
||||||
&stopped,
|
&stopped,
|
||||||
WorkerLifecycleRequest {
|
WorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: 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 leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
let request = || {
|
let request = || {
|
||||||
Json(server_api::RuntimeWorkerLifecycleRequest {
|
Json(server_api::RuntimeWorkerLifecycleRequest {
|
||||||
|
stop_intent: None,
|
||||||
reason: None,
|
reason: None,
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(
|
pub(crate) fn current_worker_singleton_owner(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
|
|||||||
+771
-2
@@ -13414,6 +13414,16 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"stop_intent": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/WorkerStopIntent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ticket_assignment": {
|
"ticket_assignment": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -13437,6 +13447,11 @@
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"replayed": {
|
||||||
|
"default": false,
|
||||||
|
"description": "Historical receipt replay, not a new stop or evidence of current state.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"runtime_id": {
|
"runtime_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -14784,6 +14799,259 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SubjectBodyDisposition": {
|
||||||
|
"enum": [
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"SubjectBodyMutationResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Fresh observation is independent of the historical operation receipt."
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Diagnostic"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"disposition": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyDisposition"
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id",
|
||||||
|
"disposition",
|
||||||
|
"diagnostics"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SubjectBodyObservation": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "absent",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "available",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"$ref": "#/components/schemas/WorkerSummary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"worker"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "The owner is still occupied; this must not invite a replacement launch.",
|
||||||
|
"properties": {
|
||||||
|
"diagnostics": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Diagnostic"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"const": "unavailable",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"diagnostics"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SubjectBodyRef": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"generation": {
|
||||||
|
"description": "Decimal text preserves the full singleton generation through browsers.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"runtime_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"worker_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"generation"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SubjectBodyResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"can_start": {
|
||||||
|
"description": "Account authority, not a claim that Profile/model/Runtime admission will succeed.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_stop": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyRef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyObservation"
|
||||||
|
},
|
||||||
|
"subject_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"workspace_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"workspace_id",
|
||||||
|
"subject_id",
|
||||||
|
"observation",
|
||||||
|
"can_start",
|
||||||
|
"can_stop"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SubjectBodyStartRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expected_current": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Null explicitly means the caller observed no owner, not an unspecified target.",
|
||||||
|
"properties": {
|
||||||
|
"generation": {
|
||||||
|
"description": "Decimal text preserves the full singleton generation through browsers.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"runtime_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"worker_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"generation"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"expected_observation_token": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id",
|
||||||
|
"expected_current"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SubjectBodyStopOutcome": {
|
||||||
|
"enum": [
|
||||||
|
"not_found",
|
||||||
|
"pending",
|
||||||
|
"stopped"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"SubjectBodyStopOutcomeResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyResponse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyStopOutcome",
|
||||||
|
"description": "Historical operation evidence, never the current body's running state."
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id",
|
||||||
|
"outcome"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SubjectBodyStopRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expected_current": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyRef"
|
||||||
|
},
|
||||||
|
"expected_observation_token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id",
|
||||||
|
"expected_current",
|
||||||
|
"expected_observation_token"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubjectPermissionInterruption": {
|
"SubjectPermissionInterruption": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -15561,6 +15829,17 @@
|
|||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"current_body": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyRef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Current owner remains present even when its Runtime cannot be observed."
|
||||||
|
},
|
||||||
"current_worker": {
|
"current_worker": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -15570,7 +15849,13 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Current keyed-singleton owner, when one exists. This is a live Yoi\nprojection, not state duplicated in the subjektiv store."
|
"description": "Available launch metadata, not evidence of absence when observation failed."
|
||||||
|
},
|
||||||
|
"current_worker_diagnostics": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Diagnostic"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -20361,6 +20646,22 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"WorkerStopIntent": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expected_observation_token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expected_observation_token",
|
||||||
|
"request_id"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"WorkerSummary": {
|
"WorkerSummary": {
|
||||||
"description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.",
|
"description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -22725,7 +23026,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c"
|
"x-yoi-source-digest": "sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -41910,6 +42211,474 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "subjektiv_subject_body_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "subject_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/start": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "subjektiv_subject_body_start",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "subject_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyStartRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyMutationResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "subjektiv_subject_body_stop",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "subject_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyStopRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyMutationResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/body/stop/outcome": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "subjektiv_subject_body_stop_outcome",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "subject_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyStopRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjectBodyStopOutcomeResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "subjektiv_memory_list",
|
"operationId": "subjektiv_memory_list",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
"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",
|
"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",
|
"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/subjektiv/routes.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/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.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/subjektiv/routes.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 src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.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: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: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",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export type SubjektivSubjectDeleteResponse = { subject_id: string, operation_id:
|
|||||||
|
|
||||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
|
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_md: string, behavior_md: string, };
|
||||||
|
|
||||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, state: SubjektivSubjectState, memory_fingerprint: string, created_at: string, updated_at: string, current_body?: import("./subject-body-api").SubjectBodyRef | null, current_worker_diagnostics?: Array<Diagnostic>, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||||
|
|
||||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
@@ -127,9 +127,9 @@ export type RuntimeWorkerInputRequest = { content: string; kind?: string | null;
|
|||||||
|
|
||||||
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
|
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
|
||||||
|
|
||||||
export type RuntimeWorkerLifecycleRequest = { reason?: string | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; };
|
export type RuntimeWorkerLifecycleRequest = { reason?: string | null; stop_intent?: WorkerStopIntent | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; };
|
||||||
|
|
||||||
export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
|
export type RuntimeWorkerLifecycleResult = { diagnostics?: Array<Diagnostic>; replayed?: boolean; runtime_id: string; state: WorkerOperationState; worker_id: string; };
|
||||||
|
|
||||||
export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
|
export type RuntimeWorkerListResponse = { diagnostics?: Array<Diagnostic>; items: Array<WorkerSummary>; limit: number; source: string; workspace_id: string; };
|
||||||
|
|
||||||
@@ -191,6 +191,8 @@ export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyS
|
|||||||
|
|
||||||
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||||
|
|
||||||
|
export type WorkerStopIntent = { expected_observation_token: string; request_id: string; };
|
||||||
|
|
||||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; restore_observation_token?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||||
|
|
||||||
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
|
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Generated by `cargo run -p server-api --features typescript --example generate_subject_body_api_types`.
|
||||||
|
// Do not edit manually.
|
||||||
|
|
||||||
|
import type { Diagnostic, WorkerSummary } from "./worker-launch-api";
|
||||||
|
|
||||||
|
export type SubjectBodyRef = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
/**
|
||||||
|
* Decimal text preserves the full singleton generation through browsers.
|
||||||
|
*/
|
||||||
|
generation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectBodyObservation = { "kind": "absent" } | {
|
||||||
|
"kind": "available";
|
||||||
|
worker: WorkerSummary;
|
||||||
|
} | { "kind": "unavailable"; diagnostics: Array<Diagnostic> };
|
||||||
|
|
||||||
|
export type SubjectBodyResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
subject_id: string;
|
||||||
|
current: SubjectBodyRef | null;
|
||||||
|
observation: SubjectBodyObservation;
|
||||||
|
/**
|
||||||
|
* Account authority, not a claim that Profile/model/Runtime admission will succeed.
|
||||||
|
*/
|
||||||
|
can_start: boolean;
|
||||||
|
can_stop: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectBodyStartRequest = {
|
||||||
|
request_id: string;
|
||||||
|
/**
|
||||||
|
* Null explicitly means the caller observed no owner, not an unspecified target.
|
||||||
|
*/
|
||||||
|
expected_current: SubjectBodyRef | null;
|
||||||
|
expected_observation_token: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectBodyStopRequest = {
|
||||||
|
request_id: string;
|
||||||
|
expected_current: SubjectBodyRef;
|
||||||
|
expected_observation_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectBodyDisposition = "accepted" | "rejected" | "unknown";
|
||||||
|
|
||||||
|
export type SubjectBodyMutationResponse = {
|
||||||
|
request_id: string;
|
||||||
|
disposition: SubjectBodyDisposition;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
/**
|
||||||
|
* Fresh observation is independent of the historical operation receipt.
|
||||||
|
*/
|
||||||
|
body: SubjectBodyResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubjectBodyStopOutcome = "not_found" | "pending" | "stopped";
|
||||||
|
|
||||||
|
export type SubjectBodyStopOutcomeResponse = {
|
||||||
|
request_id: string;
|
||||||
|
/**
|
||||||
|
* Historical operation evidence, never the current body's running state.
|
||||||
|
*/
|
||||||
|
outcome: SubjectBodyStopOutcome;
|
||||||
|
body: SubjectBodyResponse | null;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
// Input contract: 0.1.0 (sha256:efcd8fd0911bdafcde6cebdb812649166e043377995a75d9cb9305af6294c28a)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export function parseRuntimeWorkerLifecycleResult(
|
|||||||
exactKeys(
|
exactKeys(
|
||||||
item,
|
item,
|
||||||
["state", "runtime_id", "worker_id", "diagnostics"],
|
["state", "runtime_id", "worker_id", "diagnostics"],
|
||||||
[],
|
["replayed"],
|
||||||
"Runtime Worker lifecycle result",
|
"Runtime Worker lifecycle result",
|
||||||
);
|
);
|
||||||
const state = boundedString(
|
const state = boundedString(
|
||||||
@@ -331,6 +331,9 @@ export function parseRuntimeWorkerLifecycleResult(
|
|||||||
fail("Runtime Worker lifecycle result.state", "has an unknown value");
|
fail("Runtime Worker lifecycle result.state", "has an unknown value");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
replayed: item.replayed === undefined
|
||||||
|
? false
|
||||||
|
: boolean(item.replayed, "Runtime Worker lifecycle result.replayed"),
|
||||||
state: state as WorkerOperationState,
|
state: state as WorkerOperationState,
|
||||||
runtime_id: boundedString(
|
runtime_id: boundedString(
|
||||||
item.runtime_id,
|
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>
|
||||||
@@ -629,9 +629,11 @@ export function parseSubjektivSubjectResponse(
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"current_worker",
|
"current_worker",
|
||||||
|
"current_body",
|
||||||
|
"current_worker_diagnostics",
|
||||||
],
|
],
|
||||||
"Subject response",
|
"Subject response",
|
||||||
["current_worker"],
|
["current_worker", "current_body", "current_worker_diagnostics"],
|
||||||
);
|
);
|
||||||
const id = requiredIdentifier(record, "id");
|
const id = requiredIdentifier(record, "id");
|
||||||
if (expectedSubjectId !== undefined && id !== expectedSubjectId) {
|
if (expectedSubjectId !== undefined && id !== expectedSubjectId) {
|
||||||
@@ -660,6 +662,33 @@ export function parseSubjektivSubjectResponse(
|
|||||||
"Subject current worker",
|
"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;
|
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;
|
if (!scope.isCurrent()) return;
|
||||||
await refreshWorkspaceWorkers(scope.workspaceId);
|
await refreshWorkspaceWorkers(scope.workspaceId);
|
||||||
if (!scope.isCurrent()) return;
|
if (!scope.isCurrent()) return;
|
||||||
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
|
pushWorkspaceAlert('info', `Stop accepted for ${worker.display_name || worker.label}. Check the refreshed Worker state for confirmation.`, {
|
||||||
title: 'Worker stopped',
|
title: 'Stop accepted',
|
||||||
});
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!scope.isCurrent()) return;
|
if (!scope.isCurrent()) return;
|
||||||
|
|||||||
@@ -196,13 +196,23 @@ export function canDeleteSidebarWorker(worker: WorkerActionTarget): boolean {
|
|||||||
|
|
||||||
export async function stopSidebarWorker(
|
export async function stopSidebarWorker(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
worker: WorkerActionTarget,
|
worker: WorkerActionTarget & { restore_observation_token?: string | null },
|
||||||
fetchFn: FetchFn = fetch,
|
fetchFn: FetchFn = fetch,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
|
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
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));
|
if (!response.ok) throw new Error(await responseError(response));
|
||||||
|
|
||||||
@@ -239,7 +249,6 @@ export async function deleteSidebarWorker(
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
||||||
expected_plan_digest: plan.digest,
|
expected_plan_digest: plan.digest,
|
||||||
worker_target_ids: [candidate.target_id],
|
worker_target_ids: [candidate.target_id],
|
||||||
workdir_target_ids: [],
|
workdir_target_ids: [],
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<dt>Worker connection</dt>
|
<dt>Worker connection</dt>
|
||||||
<dd>
|
<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}
|
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
// behaviorは利用者が管理する本文、surfaceはMemoryからの生成物として分ける。
|
// behaviorは利用者が管理する本文、surfaceはMemoryからの生成物として分ける。
|
||||||
// ready-empty/ungenerated/stale/failedとrequest failureを区別し、surfaceの現在性を
|
// ready-empty/ungenerated/stale/failedとrequest failureを区別し、surfaceの現在性を
|
||||||
// 実行中Workerの履歴へ反映済みであることの証明には使わない。診断値は詳細へ分離する。
|
// 実行中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 DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||||
import SubjectDelete from '#lib/workspace/memory/SubjectDelete.svelte';
|
import SubjectDelete from '#lib/workspace/memory/SubjectDelete.svelte';
|
||||||
|
import SubjectBody from '#lib/workspace/memory/SubjectBody.svelte';
|
||||||
import { formatDate } from '#lib/workspace/api/http.ts';
|
import { formatDate } from '#lib/workspace/api/http.ts';
|
||||||
import {
|
import {
|
||||||
SubjektivSubjectCreateError,
|
SubjektivSubjectCreateError,
|
||||||
@@ -20,6 +22,11 @@
|
|||||||
type PageProps = { data: ResolvedPageData<RouteData> };
|
type PageProps = { data: ResolvedPageData<RouteData> };
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
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 subject = $state(untrack(() => data.subject.data));
|
||||||
let editingBehavior = $state(false);
|
let editingBehavior = $state(false);
|
||||||
@@ -189,9 +196,13 @@
|
|||||||
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Worker connection</span>
|
{#if accountId}
|
||||||
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
{#key JSON.stringify([accountId, data.workspaceId, data.subjectId])}
|
||||||
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
<SubjectBody {accountId} workspaceId={data.workspaceId} subjectId={data.subjectId} />
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<p role="status">Confirming account identity before loading body controls…</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Committed Memories</span>
|
<span>Committed Memories</span>
|
||||||
|
|||||||
+27
-3
@@ -1,10 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { subjectHref } from '#lib/workspace/subjektiv/routes.ts';
|
import { subjectHref } from '#lib/workspace/subjektiv/routes.ts';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||||
|
import SubjectBody from '#lib/workspace/memory/SubjectBody.svelte';
|
||||||
import SubjectConversation from '#lib/workspace/memory/SubjectConversation.svelte';
|
import SubjectConversation from '#lib/workspace/memory/SubjectConversation.svelte';
|
||||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||||
import type { PageData as RouteData } from './$types';
|
import type { PageData as RouteData } from './$types';
|
||||||
let { data }: { data: ResolvedPageData<RouteData> } = $props();
|
let { data }: { data: ResolvedPageData<RouteData> } = $props();
|
||||||
const subject = $derived(data.subject.data);
|
const subject = $derived(data.subject.data);
|
||||||
|
let accountConfirmed = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const accountId = data.accountId;
|
||||||
|
const workspaceId = data.workspaceId;
|
||||||
|
const subjectId = data.subjectId;
|
||||||
|
return untrack(() => {
|
||||||
|
const generation = browserSession.authenticationGeneration;
|
||||||
|
const stamp = browserSession.authenticationStamp;
|
||||||
|
return browserSession.subscribe((snapshot) => {
|
||||||
|
accountConfirmed = data.workspaceId === workspaceId && data.subjectId === subjectId &&
|
||||||
|
browserSession.authenticationGeneration === generation && browserSession.authenticationStamp === stamp &&
|
||||||
|
snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' &&
|
||||||
|
snapshot.value.actor?.account_id === accountId;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{subject?.role ?? 'Subject'} · Conversation · Yoi Workspace</title></svelte:head>
|
<svelte:head><title>{subject?.role ?? 'Subject'} · Conversation · Yoi Workspace</title></svelte:head>
|
||||||
@@ -13,9 +32,14 @@
|
|||||||
<a href={subjectHref(data.workspaceId, data.subjectId)}>← Subject details</a>
|
<a href={subjectHref(data.workspaceId, data.subjectId)}>← Subject details</a>
|
||||||
{#if subject}
|
{#if subject}
|
||||||
<h1 id="conversation-subject-heading">{subject.role}</h1>
|
<h1 id="conversation-subject-heading">{subject.role}</h1>
|
||||||
{#key JSON.stringify([data.accountId, data.workspaceId, subject.id])}
|
{#if accountConfirmed}
|
||||||
<SubjectConversation accountId={data.accountId} workspaceId={data.workspaceId} subjectId={subject.id} subjectName={subject.role} />
|
{#key JSON.stringify([data.accountId, data.workspaceId, subject.id])}
|
||||||
{/key}
|
<SubjectBody accountId={data.accountId} workspaceId={data.workspaceId} subjectId={subject.id} />
|
||||||
|
<SubjectConversation accountId={data.accountId} workspaceId={data.workspaceId} subjectId={subject.id} subjectName={subject.role} />
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<p role="alert">The conversation account could not be confirmed. Reopen this page after confirming your session.</p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<h1 id="conversation-subject-heading">Subject conversation</h1>
|
<h1 id="conversation-subject-heading">Subject conversation</h1>
|
||||||
<p role="alert">{data.subject.error ?? 'The Subject is unavailable. No conversation input was sent.'}</p>
|
<p role="alert">{data.subject.error ?? 'The Subject is unavailable. No conversation input was sent.'}</p>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ test("renders the subject index as the Memory product entry", () => {
|
|||||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||||
expect(screen.getByText("Not connected")).not.toBeNull();
|
expect(screen.getByText("No current body")).not.toBeNull();
|
||||||
expect(screen.queryByText("Memory fingerprint", { exact: true })).toBeNull();
|
expect(screen.queryByText("Memory fingerprint", { exact: true })).toBeNull();
|
||||||
expect(screen.getByText("1 shown · more available")).not.toBeNull();
|
expect(screen.getByText("1 shown · more available")).not.toBeNull();
|
||||||
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||||
@@ -346,8 +346,14 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
.toContain("Prefer explicit evidence.");
|
.toContain("Prefer explicit evidence.");
|
||||||
expect(screen.getByRole("article", { name: "Resident context" })).not
|
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
|
// Legacy connection fields cannot authorize or infer the current singleton body.
|
||||||
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
|
expect(screen.queryByText("Connected", { exact: true })).toBeNull();
|
||||||
|
expect(screen.queryByText("Release Worker", { exact: true })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Confirming account identity before loading body controls…",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
|
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
|
||||||
expect(screen.getByText("Memory fingerprint", { exact: true })).not
|
expect(screen.getByText("Memory fingerprint", { exact: true })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
@@ -791,7 +797,12 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
|||||||
|
|
||||||
expect(screen.getByText("None", { exact: true })).not.toBeNull();
|
expect(screen.getByText("None", { exact: true })).not.toBeNull();
|
||||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||||
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Confirming account identity before loading body controls…",
|
||||||
|
{ exact: true },
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||||
expect(screen.queryByRole("article", { name: "Resident context" }))
|
expect(screen.queryByRole("article", { name: "Resident context" }))
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
@@ -834,7 +845,12 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
|||||||
expect(screen.getByText("Generated Memory context", { exact: true })).not
|
expect(screen.getByText("Generated Memory context", { exact: true })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||||
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Confirming account identity before loading body controls…",
|
||||||
|
{ exact: true },
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
const technical = view.container.querySelector(
|
const technical = view.container.querySelector(
|
||||||
"details.subject-technical-details",
|
"details.subject-technical-details",
|
||||||
) as HTMLDetailsElement;
|
) as HTMLDetailsElement;
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
#!/usr/bin/env -S deno run -A
|
||||||
|
/** Actual Web build + synthetic HTTP only. No real Backend/Runtime is contacted. */
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join, resolve } from "@std/path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { sha256File } from "../../../tools/web-ux/src/artifacts.ts";
|
||||||
|
import {
|
||||||
|
startOwnedProcesses,
|
||||||
|
stopOwnedProcesses,
|
||||||
|
} from "../../../tools/web-ux/src/processes.ts";
|
||||||
|
import {
|
||||||
|
assertNoPageWideOverflow,
|
||||||
|
freePort,
|
||||||
|
} from "../../../tools/web-ux/browser-tests/memory_document_harness.ts";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
const run = Deno.args[0] ?? "review";
|
||||||
|
if (!/^[\w-]+$/.test(run)) throw new Error("safe unique run id required");
|
||||||
|
const output = join(root, "target/t807-web", run);
|
||||||
|
await Deno.mkdir(output); // Never replace previous evidence.
|
||||||
|
const buildRoot = join(root, "web/workspace/build");
|
||||||
|
await Deno.stat(join(buildRoot, "index.html"));
|
||||||
|
const git = async (args: string[]) => {
|
||||||
|
const result = await new Deno.Command("git", {
|
||||||
|
args,
|
||||||
|
cwd: root,
|
||||||
|
stdout: "piped",
|
||||||
|
}).output();
|
||||||
|
assert(result.success);
|
||||||
|
return new TextDecoder().decode(result.stdout).trim();
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend-or-Runtime",
|
||||||
|
source: {
|
||||||
|
commit: await git(["rev-parse", "HEAD"]),
|
||||||
|
status: await git(["status", "--short"]),
|
||||||
|
},
|
||||||
|
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
|
||||||
|
browser: "",
|
||||||
|
status: "running",
|
||||||
|
visualVerdict: "unreviewed",
|
||||||
|
expectedFaults: [
|
||||||
|
"structured session.record rejection",
|
||||||
|
"unknown start/stop receipts",
|
||||||
|
"stale generation rejection",
|
||||||
|
"503 body GET",
|
||||||
|
"navigation teardown aborts GET /workers refresh",
|
||||||
|
],
|
||||||
|
captures: [] as unknown[],
|
||||||
|
assertions: [] as string[],
|
||||||
|
failures: [] as string[],
|
||||||
|
};
|
||||||
|
const persist = () =>
|
||||||
|
Deno.writeTextFile(
|
||||||
|
join(output, "review-context.json"),
|
||||||
|
JSON.stringify(manifest, null, 2),
|
||||||
|
);
|
||||||
|
const port = await freePort();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const owned = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: "synthetic-memory-http",
|
||||||
|
command: Deno.execPath(),
|
||||||
|
cwd: join(root, "tools/web-ux"),
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--cached-only",
|
||||||
|
"-A",
|
||||||
|
"browser-tests/memory_document_fixture_server.ts",
|
||||||
|
String(port),
|
||||||
|
buildRoot,
|
||||||
|
"3",
|
||||||
|
],
|
||||||
|
readyUrl: `${base}/health`,
|
||||||
|
}],
|
||||||
|
join(output, "scenario.json"),
|
||||||
|
join(output, "logs"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
manifest.browser = browser.version();
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390]) {
|
||||||
|
const prefix = `${colorScheme}-${width}`;
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let accountId = "owner";
|
||||||
|
let observation: "absent" | "idle" | "stopped" | "unavailable" =
|
||||||
|
"absent";
|
||||||
|
let permission = true;
|
||||||
|
let getFailure = false;
|
||||||
|
let action: "reject" | "unknown" | "accepted" | "stale" = "reject";
|
||||||
|
let outcome: "pending" | "stopped" = "pending";
|
||||||
|
let generation = "17";
|
||||||
|
const posts: { path: string; body: string }[] = [];
|
||||||
|
const owner = () => ({
|
||||||
|
runtime_id: "runtime",
|
||||||
|
worker_id: "worker",
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
function body(workspace: string, subject: string) {
|
||||||
|
return {
|
||||||
|
workspace_id: workspace,
|
||||||
|
subject_id: subject,
|
||||||
|
can_start: permission,
|
||||||
|
can_stop: permission,
|
||||||
|
current: observation === "absent" ? null : owner(),
|
||||||
|
observation: observation === "absent"
|
||||||
|
? { kind: "absent" }
|
||||||
|
: observation === "unavailable"
|
||||||
|
? {
|
||||||
|
kind: "unavailable",
|
||||||
|
diagnostics: [{
|
||||||
|
code: "runtime_unavailable",
|
||||||
|
severity: "warning",
|
||||||
|
message:
|
||||||
|
"Runtime observation unavailable; no replacement body will be started.",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: "available",
|
||||||
|
worker: {
|
||||||
|
runtime_id: "runtime",
|
||||||
|
worker_id: "worker",
|
||||||
|
resource_key: "W-1",
|
||||||
|
host_id: "host",
|
||||||
|
label: "Conversation body",
|
||||||
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: workspace,
|
||||||
|
workspace_id: workspace,
|
||||||
|
},
|
||||||
|
implementation: { kind: "embedded", display_hint: "" },
|
||||||
|
availability: "observed",
|
||||||
|
state: observation,
|
||||||
|
worker_state: observation === "stopped"
|
||||||
|
? null
|
||||||
|
: { last_command_id: 1, state: { kind: "idle" } },
|
||||||
|
restore_observation_token:
|
||||||
|
`token-${generation}-${observation}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await context.route("**/api/auth/whoami", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const value = await response.json();
|
||||||
|
value.actor.account_id = accountId;
|
||||||
|
await route.fulfill({ response, json: value });
|
||||||
|
});
|
||||||
|
// The second Workspace uses a synthetic, silent protocol channel too.
|
||||||
|
await context.routeWebSocket(
|
||||||
|
"**/api/w/other-review/protocol/ws",
|
||||||
|
(socket) => {
|
||||||
|
socket.onMessage(() => {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Reuse the existing isolated fixture for a second Workspace identity.
|
||||||
|
await context.route(
|
||||||
|
/\/api\/w\/other-review(?:\/.*)?$/,
|
||||||
|
async (route) => {
|
||||||
|
const response = await route.fetch({
|
||||||
|
url: route.request().url().replace(
|
||||||
|
"/api/w/other-review/",
|
||||||
|
"/api/w/memory-review/",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
response,
|
||||||
|
body: (await response.text()).replaceAll(
|
||||||
|
"memory-review",
|
||||||
|
"other-review",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await context.route(
|
||||||
|
/\/api\/w\/[^/]+\/subjektiv\/subjects\/[^/]+\/body(?:\/.*)?$/,
|
||||||
|
async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const path = new URL(request.url()).pathname;
|
||||||
|
const [, workspace, subject] = path.match(
|
||||||
|
/\/api\/w\/([^/]+)\/subjektiv\/subjects\/([^/]+)\/body/,
|
||||||
|
)!;
|
||||||
|
if (request.method() === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: getFailure ? 503 : 200,
|
||||||
|
json: getFailure
|
||||||
|
? { error: "Synthetic body observation unavailable" }
|
||||||
|
: body(workspace, subject),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = request.postData()!;
|
||||||
|
posts.push({ path, body: raw });
|
||||||
|
const input = JSON.parse(raw);
|
||||||
|
assert(!("accountId" in input));
|
||||||
|
if (path.endsWith("/outcome")) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: { request_id: input.request_id, outcome, body: null },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "stale") generation = "18";
|
||||||
|
if (action === "accepted" && path.endsWith("/start")) {
|
||||||
|
observation = "idle";
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
request_id: input.request_id,
|
||||||
|
disposition: action === "reject" || action === "stale"
|
||||||
|
? "rejected"
|
||||||
|
: action,
|
||||||
|
diagnostics: action === "reject"
|
||||||
|
? [{
|
||||||
|
code: "permission_missing",
|
||||||
|
severity: "error",
|
||||||
|
message:
|
||||||
|
"Required permission session.record is missing. Ask an administrator to review the default profile; no permissions were added.",
|
||||||
|
}]
|
||||||
|
: action === "stale"
|
||||||
|
? [{
|
||||||
|
code: "stale_owner",
|
||||||
|
severity: "warning",
|
||||||
|
message:
|
||||||
|
"The current body generation changed. Refresh before acting.",
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
body: body(workspace, subject),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
let documentStatus: number | null = null;
|
||||||
|
page.on(
|
||||||
|
"pageerror",
|
||||||
|
(e) => errors.push({ kind: "page", message: e.message }),
|
||||||
|
);
|
||||||
|
page.on("console", (e) => {
|
||||||
|
if (["warning", "error"].includes(e.type())) {
|
||||||
|
errors.push({ kind: "console", message: e.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on(
|
||||||
|
"requestfailed",
|
||||||
|
(r) =>
|
||||||
|
errors.push({
|
||||||
|
kind: "request",
|
||||||
|
url: r.url(),
|
||||||
|
message: r.failure()?.errorText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.request().isNavigationRequest()) documentStatus = r.status();
|
||||||
|
if (r.status() >= 400) {
|
||||||
|
errors.push({ kind: "http", status: r.status(), url: r.url() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const panel = () =>
|
||||||
|
page.getByRole("region", { name: "Conversation body", exact: true });
|
||||||
|
const button = (name: string) =>
|
||||||
|
panel().getByRole("button", { name, exact: true });
|
||||||
|
async function ready() {
|
||||||
|
await page.locator(".subject-body header button:not(:disabled)")
|
||||||
|
.waitFor();
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
await ready();
|
||||||
|
await button("Refresh body state").click();
|
||||||
|
await ready();
|
||||||
|
}
|
||||||
|
async function capture(name: string, expectedErrors: string[] = []) {
|
||||||
|
await panel().scrollIntoViewIfNeeded();
|
||||||
|
const id = `${prefix}-${name}`;
|
||||||
|
const image = join(output, `${id}.png`);
|
||||||
|
const aria = join(output, `${id}.aria.txt`);
|
||||||
|
const panelImage = join(output, `${id}.panel.png`);
|
||||||
|
await page.screenshot({ path: image });
|
||||||
|
await panel().screenshot({ path: panelImage });
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
aria,
|
||||||
|
await page.locator("body").ariaSnapshot(),
|
||||||
|
);
|
||||||
|
manifest.captures.push({
|
||||||
|
id,
|
||||||
|
route: new URL(page.url()).pathname,
|
||||||
|
accountId,
|
||||||
|
viewport: page.viewportSize(),
|
||||||
|
theme: colorScheme,
|
||||||
|
documentStatus,
|
||||||
|
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||||
|
bodyText: await panel().innerText(),
|
||||||
|
errors: [...errors],
|
||||||
|
expectedErrors,
|
||||||
|
image,
|
||||||
|
imageSha256: await sha256File(image),
|
||||||
|
panelImage,
|
||||||
|
panelImageSha256: await sha256File(panelImage),
|
||||||
|
accessibilitySnapshot: aria,
|
||||||
|
reviewed: false,
|
||||||
|
});
|
||||||
|
await persist();
|
||||||
|
await assertNoPageWideOverflow(page);
|
||||||
|
}
|
||||||
|
// Enter through the ordinary Subjects list, not a special-purpose shell.
|
||||||
|
await page.goto(`${base}/w/memory-review/memory`);
|
||||||
|
await page.locator('a.subject-row[href$="/empty-subject"]').click();
|
||||||
|
await panel().getByText("No current conversation body.", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await ready();
|
||||||
|
await button("Refresh body state").focus();
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
assert(
|
||||||
|
await button("Start conversation body").evaluate((el) =>
|
||||||
|
el === el.ownerDocument.activeElement
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await capture("before-absent-focus");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await panel().getByText("Request rejected.", { exact: true }).waitFor();
|
||||||
|
await ready();
|
||||||
|
await capture("permission-rejection");
|
||||||
|
action = "unknown";
|
||||||
|
await button("Start conversation body").click();
|
||||||
|
await panel().getByText("Request outcome unknown.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
const unknownInput = posts.at(-1)!.body;
|
||||||
|
observation = "idle";
|
||||||
|
await refresh();
|
||||||
|
assertEquals(await button("Start conversation body").count(), 0);
|
||||||
|
await capture("unknown-after-get");
|
||||||
|
await page.reload();
|
||||||
|
await panel().getByText(/An unresolved start request is retained/)
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
action = "accepted";
|
||||||
|
await button("Retry same start request").click();
|
||||||
|
await panel().getByText("Request accepted.", { exact: true }).waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(posts.at(-1)!.body, unknownInput);
|
||||||
|
await capture("running-reconciled");
|
||||||
|
action = "unknown";
|
||||||
|
await button("Stop conversation body").click();
|
||||||
|
await panel().getByText("Request outcome unknown.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
const stopInput = posts.at(-1)!.body;
|
||||||
|
await button("Check stop outcome").click();
|
||||||
|
await panel().getByText("Stop outcome pending.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
observation = "stopped";
|
||||||
|
await refresh();
|
||||||
|
assertEquals(await button("Restart conversation body").count(), 0);
|
||||||
|
await capture("stopped-but-unresolved");
|
||||||
|
outcome = "stopped";
|
||||||
|
await button("Check stop outcome").click();
|
||||||
|
await panel().getByText("Historical stop confirmed.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(posts.at(-1)!.body, stopInput);
|
||||||
|
await capture("stop-confirmed");
|
||||||
|
action = "accepted";
|
||||||
|
await button("Restart conversation body").click();
|
||||||
|
await panel().getByText("Request accepted.", { exact: true }).waitFor();
|
||||||
|
await ready();
|
||||||
|
const restart = JSON.parse(posts.at(-1)!.body);
|
||||||
|
assertEquals(restart.expected_observation_token, "token-17-stopped");
|
||||||
|
assertEquals(restart.expected_current.generation, "17");
|
||||||
|
assert(restart.request_id !== JSON.parse(stopInput).request_id);
|
||||||
|
await capture("restarted-running");
|
||||||
|
action = "stale";
|
||||||
|
await button("Stop conversation body").click();
|
||||||
|
await panel().getByText(
|
||||||
|
"The current body generation changed. Refresh before acting.",
|
||||||
|
{ exact: true },
|
||||||
|
).waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(
|
||||||
|
JSON.parse(posts.at(-1)!.body).expected_current.generation,
|
||||||
|
"17",
|
||||||
|
);
|
||||||
|
await capture("old-generation-rejected");
|
||||||
|
permission = false;
|
||||||
|
await refresh();
|
||||||
|
assertEquals(await button("Stop conversation body").count(), 0);
|
||||||
|
await capture("read-only");
|
||||||
|
permission = true;
|
||||||
|
observation = "unavailable";
|
||||||
|
await refresh();
|
||||||
|
assertEquals(await button("Start conversation body").count(), 0);
|
||||||
|
await capture("unavailable-owner");
|
||||||
|
// Account identity changes must not expose another account's retained retry.
|
||||||
|
observation = "absent";
|
||||||
|
action = "unknown";
|
||||||
|
await refresh();
|
||||||
|
await button("Start conversation body").click();
|
||||||
|
await panel().getByText("Request outcome unknown.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
const beforeIdentitySwitch = posts.length;
|
||||||
|
accountId = "other-account";
|
||||||
|
await page.reload();
|
||||||
|
await panel().getByText("No current conversation body.", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(await button("Retry same start request").count(), 0);
|
||||||
|
assertEquals(posts.length, beforeIdentitySwitch);
|
||||||
|
// Ordinary back-to-list and Subject navigation must isolate the old request too.
|
||||||
|
await page.getByRole("link", { name: "← All subjects", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.locator('a.subject-row[href$="/stale-subject"]').click();
|
||||||
|
await panel().getByText("No current conversation body.", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(await button("Retry same start request").count(), 0);
|
||||||
|
await capture("identity-subject-switched");
|
||||||
|
await button("Start conversation body").click();
|
||||||
|
await panel().getByText("Request outcome unknown.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
const beforeWorkspaceSwitch = posts.length;
|
||||||
|
await page.goto(`${base}/w/other-review/memory/stale-subject`);
|
||||||
|
await panel().getByText("No current conversation body.", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(await button("Retry same start request").count(), 0);
|
||||||
|
assertEquals(posts.length, beforeWorkspaceSwitch);
|
||||||
|
await capture("workspace-switched");
|
||||||
|
await page.goto(`${base}/w/memory-review/memory/stale-subject`);
|
||||||
|
await panel().getByText(/An unresolved start request is retained/)
|
||||||
|
.waitFor();
|
||||||
|
await ready();
|
||||||
|
assertEquals(posts.length, beforeWorkspaceSwitch);
|
||||||
|
getFailure = true;
|
||||||
|
await refresh();
|
||||||
|
await panel().getByRole("alert").waitFor();
|
||||||
|
assert(await button("Retry same start request").isDisabled());
|
||||||
|
await capture("get-failure", [
|
||||||
|
"intentional 503 body GET; browser console reports failed resource",
|
||||||
|
]);
|
||||||
|
const unexpected = errors.filter((e: any) => {
|
||||||
|
if (e.kind === "request") {
|
||||||
|
return !(e.message === "net::ERR_ABORTED" &&
|
||||||
|
/^\/api\/w\/(memory-review|other-review)\/workers$/.test(
|
||||||
|
new URL(e.url).pathname,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (e.kind === "http") {
|
||||||
|
return e.status !== 503 || !e.url.endsWith("/body");
|
||||||
|
}
|
||||||
|
if (e.kind === "console") {
|
||||||
|
return !e.message.includes("503 (Service Unavailable)");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assertEquals(unexpected, [], "unexpected browser diagnostics");
|
||||||
|
manifest.assertions.push(
|
||||||
|
`${prefix}: normal entry, keyboard focus/activation, missing session.record, unknown GET/reload/exact retry, pending stop, confirmed stop/restart, old generation, read-only, unavailable owner, account, Workspace and Subject isolation, GET failure`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest.status = "pass";
|
||||||
|
} catch (error) {
|
||||||
|
manifest.status = "fail";
|
||||||
|
manifest.failures.push(
|
||||||
|
error instanceof Error ? error.stack ?? error.message : String(error),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await persist();
|
||||||
|
await browser?.close();
|
||||||
|
await stopOwnedProcesses(owned);
|
||||||
|
}
|
||||||
|
console.log(output);
|
||||||
@@ -297,3 +297,25 @@ Deno.test("Restore parser bounds diagnostics, validates wrapper/summary identity
|
|||||||
"wire contract",
|
"wire contract",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("lifecycle replay metadata is optional but validated when present", () => {
|
||||||
|
const receipt = {
|
||||||
|
state: "accepted",
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
worker_id: "worker-1",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
assert(
|
||||||
|
parseRuntimeWorkerLifecycleResult(receipt).replayed === false,
|
||||||
|
"missing replay is not a replay",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
parseRuntimeWorkerLifecycleResult({ ...receipt, replayed: true })
|
||||||
|
.replayed === true,
|
||||||
|
"replay flag lost",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseRuntimeWorkerLifecycleResult({ ...receipt, replayed: "true" }),
|
||||||
|
"must be a boolean",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user