From 6091fafcacbc01227252eabc9de825a64ed5f6bc Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 06:00:31 +0900 Subject: [PATCH 01/31] feat: connect subjektiv extraction lifecycle --- crates/manifest/src/config.rs | 107 +++- crates/manifest/src/lib.rs | 83 ++- crates/manifest/src/profile.rs | 5 + crates/server-api/src/lib.rs | 152 +++++ crates/worker-runtime/src/worker_backend.rs | 63 +- crates/worker-runtime/src/worker_source.rs | 12 + crates/worker/src/controller.rs | 28 + .../src/feature/builtin/memory_lifecycle.rs | 411 ++++++++++++- .../feature/builtin/memory_staging_output.rs | 186 +++++- crates/worker/src/worker.rs | 8 + crates/workspace-server/src/server.rs | 521 ++++++++++++++++ crates/workspace-server/src/subjektiv.rs | 562 +++++++++++++++++- docs/design/subjektiv-store.md | 47 +- 13 files changed, 2082 insertions(+), 103 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 75caeee9d..d4e135111 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -19,9 +19,10 @@ use crate::{ CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig, MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig, - MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig, - SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, - WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta, + MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig, + ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig, + ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig, + WorkerManifest, WorkerMeta, }; /// Partial-form Worker manifest. Every field is optional; one or more @@ -76,6 +77,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub memory: Option, #[serde(default)] + pub subjektiv: Option, + #[serde(default)] pub web: Option, #[serde(default)] pub image: Option, @@ -104,6 +107,11 @@ impl FeatureConfigPartial { Self { task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge), memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge), + subjektiv: merge_option( + self.subjektiv, + other.subjektiv, + SubjektivFeatureConfigPartial::merge, + ), web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge), sub_worker: merge_option( @@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial { pub request_enabled: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjektivFeatureConfigPartial { + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub extraction: Option, +} + +impl SubjektivFeatureConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + enabled: other.enabled.or(self.enabled), + extraction: merge_option( + self.extraction, + other.extraction, + MemoryExtractionProfileConfigPartial::merge, + ), + } + } +} + impl MemoryFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { @@ -331,6 +361,10 @@ impl From for FeatureConfig { .memory .map(ResolvedMemoryFeatureConfig::from) .unwrap_or_default(), + subjektiv: value + .subjektiv + .map(ResolvedSubjektivFeatureConfig::from) + .unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(), sub_worker: value @@ -447,6 +481,42 @@ impl From for MemoryFeatureConfigPartial { } } +impl From for ResolvedSubjektivFeatureConfig { + fn from(value: SubjektivFeatureConfigPartial) -> Self { + let extraction = value.extraction.unwrap_or_default(); + Self { + profile: SubjektivFeatureProfileConfig { + enabled: value.enabled.unwrap_or_default(), + extraction: MemoryExtractionProfileConfig { + enabled: extraction.enabled.unwrap_or(true), + model: extraction.model, + reasoning: extraction.reasoning, + threshold: extraction.threshold.or(Some(50_000)), + worker_max_turns: extraction + .worker_max_turns + .or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), + }, + }, + workspace_settings: None, + } + } +} + +impl From for SubjektivFeatureConfigPartial { + fn from(value: ResolvedSubjektivFeatureConfig) -> Self { + Self { + enabled: Some(value.profile.enabled), + extraction: Some(MemoryExtractionProfileConfigPartial { + enabled: Some(value.profile.extraction.enabled), + model: value.profile.extraction.model, + reasoning: value.profile.extraction.reasoning, + threshold: value.profile.extraction.threshold, + worker_max_turns: value.profile.extraction.worker_max_turns, + }), + } + } +} + impl From for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { @@ -500,6 +570,7 @@ impl From for FeatureConfigPartial { Self { task: Some(value.task.into()), memory: Some(value.memory.into()), + subjektiv: Some(value.subjektiv.into()), web: Some(value.web.into()), image: Some(value.image.into()), sub_worker: Some(value.sub_worker.into()), @@ -2490,6 +2561,36 @@ directories = [".claude/skills", ".cursor/skills"] ); } + #[test] + fn subjektiv_config_is_explicit_and_roundtrips_without_subject_identity() { + let config = WorkerManifestConfig::from_toml( + r#" +[worker] +name = "subject-worker" + +[feature.subjektiv] +enabled = true + +[feature.subjektiv.extraction] +enabled = true +threshold = 321 +worker_max_turns = 7 +"#, + ) + .unwrap(); + let partial = config.feature.subjektiv.unwrap(); + assert_eq!(partial.enabled, Some(true)); + assert_eq!(partial.extraction.as_ref().unwrap().threshold, Some(321)); + + let resolved = ResolvedSubjektivFeatureConfig::from(partial); + assert!(resolved.profile.enabled); + assert_eq!(resolved.profile.extraction.threshold, Some(321)); + assert_eq!(resolved.profile.extraction.worker_max_turns, Some(7)); + let encoded = serde_json::to_value(SubjektivFeatureConfigPartial::from(resolved)).unwrap(); + assert!(encoded.get("subject_id").is_none()); + assert_eq!(encoded["enabled"], true); + } + #[test] fn merge_preserves_ref() { let lower = WorkerManifestConfig { diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 5a53f084c..9986dad68 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -108,6 +108,11 @@ pub struct FeatureConfig { pub task: FeatureFlagConfig, #[serde(default)] pub memory: ResolvedMemoryFeatureConfig, + /// Subject-scoped Memory extraction. Subject identity is resolved by the + /// Workspace host from the Worker's keyed singleton lease, never from this + /// model-visible manifest. + #[serde(default)] + pub subjektiv: ResolvedSubjektivFeatureConfig, #[serde(default)] pub web: FeatureFlagConfig, #[serde(default)] @@ -139,6 +144,7 @@ impl Default for FeatureConfig { Self { task: FeatureFlagConfig::disabled(), memory: ResolvedMemoryFeatureConfig::default(), + subjektiv: ResolvedSubjektivFeatureConfig::default(), web: FeatureFlagConfig::disabled(), image: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(), @@ -350,6 +356,79 @@ impl ResolvedMemoryFeatureConfig { } } +/// Profile-owned subjektiv activation and extraction policy. The subject id is +/// deliberately absent: the Workspace host binds it from `subjektiv:` +/// singleton ownership for each operation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct SubjektivFeatureProfileConfig { + pub enabled: bool, + pub extraction: MemoryExtractionProfileConfig, +} + +impl Default for SubjektivFeatureProfileConfig { + fn default() -> Self { + Self { + enabled: false, + extraction: MemoryExtractionProfileConfig::default(), + } + } +} + +/// Immutable subjektiv execution configuration persisted in a resolved Worker +/// Manifest. Workspace settings provide only trusted extraction language and +/// are not subject identity. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default, deny_unknown_fields)] +pub struct ResolvedSubjektivFeatureConfig { + pub profile: SubjektivFeatureProfileConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_settings: Option, +} + +impl ResolvedSubjektivFeatureConfig { + pub fn enabled(&self) -> bool { + self.profile.enabled + } + + pub fn bind_workspace_settings( + &mut self, + settings: WorkspaceMemorySettingsSnapshot, + ) -> Result<(), &'static str> { + if !self.profile.enabled { + if self.workspace_settings.is_some() { + return Err("disabled subjektiv feature must not carry Workspace settings"); + } + return Ok(()); + } + if self.workspace_settings.is_some() { + return Err("subjektiv Workspace settings are already bound"); + } + self.workspace_settings = Some(settings); + Ok(()) + } + + pub fn workspace_settings(&self) -> Option { + self.workspace_settings.clone() + } + + pub fn validate_execution(&self) -> Result<(), &'static str> { + if self.profile.enabled && self.workspace_settings.is_none() { + return Err("enabled subjektiv feature requires trusted Workspace settings"); + } + if !self.profile.enabled && self.workspace_settings.is_some() { + return Err("disabled subjektiv feature must not carry Workspace settings"); + } + if let Some(settings) = &self.workspace_settings + && (settings.settings_revision == 0 + || !is_normalized_workspace_memory_language(&settings.language)) + { + return Err("subjektiv Workspace settings snapshot metadata is invalid"); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct TicketFeatureConfig { #[serde(default)] @@ -963,7 +1042,9 @@ impl Default for CompactionConfig { impl WorkerManifest { pub fn requires_persisted_execution_snapshot(&self) -> bool { - self.profile.is_some() || self.feature.memory.workspace_settings.is_some() + self.profile.is_some() + || self.feature.memory.workspace_settings.is_some() + || self.feature.subjektiv.workspace_settings.is_some() } /// Parse a manifest from a TOML string. diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index b55df5678..2e59e18ff 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement { Flow, ManageWorkdir, Memory, + Subjektiv, MergeRequest, Objective, Orchestration, @@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement { Self::Flow => formatter.write_str("feature.flow"), Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"), Self::Memory => formatter.write_str("feature.memory"), + Self::Subjektiv => formatter.write_str("feature.subjektiv"), Self::MergeRequest => formatter.write_str("feature.merge_request"), Self::Objective => formatter.write_str("feature.objective"), Self::Orchestration => formatter.write_str("feature.orchestration"), @@ -185,6 +187,9 @@ pub fn validate_profile_execution_target( if feature.memory.profile.enabled || feature.memory.profile.staging_tools { requirements.insert(WorkspaceAuthorityRequirement::Memory); } + if feature.subjektiv.profile.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Subjektiv); + } if feature.merge_request.show || feature.merge_request.open || feature.merge_request.review diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 7bed30902..cf2fbdb9c 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -234,6 +234,13 @@ impl_openapi_schema!( MemoryBackendResponse, MemoryConsolidateStagingRequest, MemoryConsolidationResponse, + SubjektivSubjectCreateRequest, + SubjektivSubjectResponse, + SubjektivSubjectWorkerStartRequest, + SubjektivStageCandidateRequest, + SubjektivStageCandidateResponse, + SubjektivRecordSessionRequest, + SubjektivRecordSessionResponse, SkillCatalogResponse, SkillDetailResponse, SkillActivationResponse, @@ -1089,6 +1096,87 @@ pub trait ServerApi { #[body] request: MemoryConsolidateStagingRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/subjects", + status = 201, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_create( + &self, + #[path] workspace_id: String, + #[body] request: SubjektivSubjectCreateRequest, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_get( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_retire( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_worker_start( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: SubjektivSubjectWorkerStartRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/staging", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_stage_candidate( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: SubjektivStageCandidateRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/sessions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_record_session( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: SubjektivRecordSessionRequest, + ) -> Result; + #[get( "/api/w/{workspace_id}/skills", status = 200, @@ -4356,6 +4444,68 @@ pub struct MemoryConsolidationResponse { pub total_bytes: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSubjectState { + Active, + Retired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectCreateRequest { + pub role: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectResponse { + pub id: String, + pub role: String, + pub state: SubjektivSubjectState, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub store_revision: u64, + pub created_at: String, + pub updated_at: String, + /// Current keyed-singleton owner, when one exists. This is a live Yoi + /// projection, not state duplicated in the subjektiv store. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_worker: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectWorkerStartRequest { + pub worker: CreateWorkspaceWorkerRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivStageCandidateRequest { + /// Committed parent Session identity captured by the Worker host. + pub session_id: String, + pub operation: memory::backend::MemoryStageCandidateOperation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivStageCandidateResponse { + pub staging_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivRecordSessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivRecordSessionResponse { + pub subject_id: String, + pub session_id: String, +} + pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; @@ -11521,6 +11671,8 @@ mod openapi_artifact_tests { "worker_control_stop", "worker_observation_capture", "worker_observation_sessions", + "subjektiv_stage_candidate", + "subjektiv_record_session", "workspace_worker_discovery", "workspace_worker_remove", ]; diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 381a972a2..5c1673a16 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -960,14 +960,28 @@ fn bind_workspace_memory_settings( snapshot.workspace_id, workspace_api.workspace_id )); } + if manifest.feature.memory.profile.enabled { + manifest + .feature + .memory + .bind_workspace_settings(snapshot.clone()) + .map_err(str::to_string)?; + } + if manifest.feature.subjektiv.profile.enabled { + manifest + .feature + .subjektiv + .bind_workspace_settings(snapshot.clone()) + .map_err(str::to_string)?; + } manifest .feature .memory - .bind_workspace_settings(snapshot.clone()) + .validate_execution() .map_err(str::to_string)?; manifest .feature - .memory + .subjektiv .validate_execution() .map_err(str::to_string)?; Ok(()) @@ -985,24 +999,35 @@ fn validate_worker_memory_settings( .memory .validate_execution() .map_err(str::to_string)?; - if !manifest.feature.memory.profile.enabled { - return Ok(()); - } - let actual = manifest + manifest .feature - .memory - .workspace_settings() - .ok_or_else(|| { - "Workspace Worker restored without its bound Memory settings snapshot".to_string() - })?; - if &actual != expected { - return Err(format!( - "Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}", - expected.workspace_id, - expected.settings_revision, - actual.workspace_id, - actual.settings_revision - )); + .subjektiv + .validate_execution() + .map_err(str::to_string)?; + for (feature, enabled, actual) in [ + ( + "Memory", + manifest.feature.memory.profile.enabled, + manifest.feature.memory.workspace_settings(), + ), + ( + "subjektiv", + manifest.feature.subjektiv.profile.enabled, + manifest.feature.subjektiv.workspace_settings(), + ), + ] { + if enabled && actual.as_ref() != Some(expected) { + let actual = actual.ok_or_else(|| { + format!("Workspace Worker restored without its bound {feature} settings snapshot") + })?; + return Err(format!( + "Workspace Worker {feature} settings snapshot mismatch: expected {} revision {}, restored {} revision {}", + expected.workspace_id, + expected.settings_revision, + actual.workspace_id, + actual.settings_revision + )); + } } Ok(()) } diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 1a7e5d4da..3c6dbfccf 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -564,6 +564,18 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { .await, ) } + WorkspaceServerOperation::SubjektivStageCandidate(request) => { + generated_workspace_response( + client + .subjektiv_stage_candidate(workspace_id, request) + .await, + ) + } + WorkspaceServerOperation::SubjektivRecordSession(request) => { + generated_workspace_response( + client.subjektiv_record_session(workspace_id, request).await, + ) + } } }) }) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index f925b7e60..6b5b2efe9 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1242,6 +1242,18 @@ where ), ); } + let memory_profile = &worker.manifest().feature.memory.profile; + let subjektiv_profile = &worker.manifest().feature.subjektiv.profile; + if memory_profile.enabled + && memory_profile.extraction.enabled + && subjektiv_profile.enabled + && subjektiv_profile.extraction.enabled + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker", + )); + } let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare( worker.manifest(), worker.workspace_client_handle(), @@ -1276,6 +1288,22 @@ where { feature_registry.add_module(memory_lifecycle); } + if let Some(subjektiv_lifecycle) = + crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( + worker.manifest_lifecycle_features_enabled(), + worker.manifest().feature.subjektiv.clone(), + worker.committed_session_capture_handle(), + worker.session_extension_handle(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + worker.working_event_sender(), + )? + { + feature_registry.add_module(subjektiv_lifecycle); + } if sub_worker_enabled && !worker_enabled { feature_registry.add_module( crate::feature::builtin::manage_worker::sub_worker_control_feature( diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index a6b095c58..6e3027737 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -39,8 +39,16 @@ use manifest::WorkerManifest; use protocol::Event; const TASK_NAME: &str = "memory-lifecycle"; +const SUBJEKTIV_TASK_NAME: &str = "subjektiv-memory-lifecycle"; +const SUBJEKTIV_EXTRACT_DOMAIN: &str = "subjektiv.extract.v1"; const TASK_TIMEOUT: Duration = Duration::from_secs(300); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExtractionTarget { + WorkspaceMemory, + Subjektiv, +} + /// Parent-Worker lifecycle Feature that observes committed runs and schedules /// bounded extraction work. It owns the Memory pointer, audit, restricted /// Internal Worker, and staging disposition; Worker core owns only generic @@ -50,9 +58,17 @@ pub(crate) struct MemoryLifecycleFeature { task: MemoryLifecycleTask, } +/// Subject-scoped extraction is a distinct Feature module even though it reuses +/// the mature committed-capture/extract/pointer lifecycle implementation. +#[derive(Clone)] +pub(crate) struct SubjektivLifecycleFeature { + task: MemoryLifecycleTask, +} + #[derive(Clone)] struct MemoryLifecycleTask { config: manifest::ResolvedMemoryFeatureConfig, + target: ExtractionTarget, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, workspace_client: Arc, @@ -120,6 +136,7 @@ impl MemoryLifecycleFeature { Self { task: MemoryLifecycleTask { config, + target: ExtractionTarget::WorkspaceMemory, capture, extensions, workspace_client, @@ -133,6 +150,63 @@ impl MemoryLifecycleFeature { } } +impl SubjektivLifecycleFeature { + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_resolved_config( + lifecycle_enabled: bool, + config: manifest::ResolvedSubjektivFeatureConfig, + capture: CommittedSessionCaptureHandle, + extensions: SessionExtensionHandle, + workspace_client: Arc, + manifest: WorkerManifest, + client: Box, + prompts: Arc>, + workspace_context: WorkerWorkspaceContext, + event_tx: Option>, + ) -> std::io::Result> { + if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { + return Ok(None); + } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv extraction requires Backend Workspace API authority", + )); + } + let memory_config = manifest::ResolvedMemoryFeatureConfig { + profile: manifest::MemoryFeatureProfileConfig { + enabled: true, + staging_tools: false, + resident: manifest::MemoryResidentProfileConfig { + inject_summary: false, + }, + extraction: config.profile.extraction, + consolidation: manifest::MemoryConsolidationProfileConfig { + request_enabled: false, + }, + }, + workspace_settings: config.workspace_settings, + }; + Ok(Some(Self { + task: MemoryLifecycleTask { + config: memory_config, + target: ExtractionTarget::Subjektiv, + capture, + extensions, + workspace_client, + manifest, + client, + prompts, + workspace_context, + event_tx, + }, + })) + } +} + impl FeatureModule for MemoryLifecycleFeature { fn descriptor(&self) -> FeatureDescriptor { FeatureDescriptor::builtin("memory-lifecycle", "Memory Lifecycle") @@ -152,17 +226,54 @@ impl FeatureModule for MemoryLifecycleFeature { } } +impl FeatureModule for SubjektivLifecycleFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("subjektiv-memory-lifecycle", "subjektiv Memory Lifecycle") + .with_description( + "Observes terminal committed parent runs and stages bounded Memory candidates for the host-connected subject.", + ) + .with_background_task(BackgroundTaskDeclaration::worker_managed( + SUBJEKTIV_TASK_NAME, + "Extract provenance-preserving candidates into subject-scoped staging after committed runs.", + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context + .background_tasks() + .register(subjektiv_lifecycle_task_spec(), self.task.clone()) + } +} + fn memory_lifecycle_task_spec() -> BackgroundTaskSpec { - let declaration = BackgroundTaskDeclaration::worker_managed( + lifecycle_task_spec( TASK_NAME, "Extract provenance-preserving Memory candidates and request Backend consolidation after committed runs.", - ); + ) +} + +fn subjektiv_lifecycle_task_spec() -> BackgroundTaskSpec { + lifecycle_task_spec( + SUBJEKTIV_TASK_NAME, + "Extract provenance-preserving candidates into subject-scoped staging after committed runs.", + ) +} + +fn lifecycle_task_spec(name: &'static str, description: &'static str) -> BackgroundTaskSpec { + let declaration = BackgroundTaskDeclaration::worker_managed(name, description); let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT); spec.trigger = BackgroundTaskTrigger::RunCommitted; spec } impl MemoryLifecycleTask { + fn pointer_domain(&self) -> &'static str { + match self.target { + ExtractionTarget::WorkspaceMemory => extract::EXTRACT_DOMAIN, + ExtractionTarget::Subjektiv => SUBJEKTIV_EXTRACT_DOMAIN, + } + } + fn extraction_manifest(&self) -> WorkerManifest { let mut manifest = self.manifest.clone(); if let Some(model) = self.config.profile.extraction.model.clone() { @@ -192,6 +303,11 @@ impl MemoryLifecycleTask { .map(model_audit_from_manifest), ) .with_memory_settings(&self.config); + let audit = if self.target == ExtractionTarget::Subjektiv { + audit.without_legacy_backend() + } else { + audit + }; let capture = match self.capture.capture() { Ok(capture) => capture, Err(error) => { @@ -223,7 +339,7 @@ impl MemoryLifecycleTask { .await; return Ok(()); } - let pointer = match extract_pointer(&capture) { + let pointer = match extract_pointer(&capture, self.pointer_domain()) { Ok(pointer) => pointer, Err(error) => { audit @@ -333,12 +449,21 @@ impl MemoryLifecycleTask { None, ) .await; - let output_state = MemoryStagingOutputState::new( - view.clone(), - Arc::clone(&self.workspace_client), - source, - audit.run_id.to_string(), - ); + let output_state = match self.target { + ExtractionTarget::WorkspaceMemory => MemoryStagingOutputState::new( + view.clone(), + Arc::clone(&self.workspace_client), + source, + audit.run_id.to_string(), + ), + ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv( + view.clone(), + Arc::clone(&self.workspace_client), + source, + audit.run_id.to_string(), + capture.session_id.clone(), + ), + }; let client = if let Some(model) = self.config.profile.extraction.model.as_ref() { match crate::model_client::build_client(model) { Ok(client) => client, @@ -485,6 +610,23 @@ impl MemoryLifecycleTask { ExtractionDisposition::Completed => {} } + if self.target == ExtractionTarget::Subjektiv + && let Err(reason) = self.record_subjektiv_session(&capture.session_id) + { + audit + .emit( + self.workspace_client.as_ref(), + self.event_tx.as_ref(), + memory::audit::WorkerLifecycleStatus::Failed, + reason, + usage_audit, + extract_audit, + None, + ) + .await; + return Ok(()); + } + context.generation_fence.ensure_current()?; let next_pointer = memory::ExtractPointerPayload { processed_through_entry: capture.entry_count - 1, @@ -494,7 +636,7 @@ impl MemoryLifecycleTask { let payload = serde_json::to_value(&next_pointer).map_err(hook_internal)?; if !self .extensions - .append_if_current(&capture.location(), extract::EXTRACT_DOMAIN, payload) + .append_if_current(&capture.location(), self.pointer_domain(), payload) .map_err(hook_internal)? { audit @@ -549,6 +691,35 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { } impl MemoryLifecycleTask { + fn record_subjektiv_session(&self, session_id: &str) -> Result<(), String> { + let response = self + .workspace_client + .execute_server_operation( + crate::worker::WorkspaceServerOperation::SubjektivRecordSession( + server_api::SubjektivRecordSessionRequest { + session_id: session_id.to_string(), + }, + ), + ) + .map_err(|error| format!("record subjektiv Session attribution failed: {error}"))?; + if !response.is_success() { + return Err(format!( + "record subjektiv Session attribution returned HTTP {}: {}", + response.status, response.body + )); + } + let output: server_api::SubjektivRecordSessionResponse = + serde_json::from_str(&response.body).map_err(|error| { + format!("decode subjektiv Session attribution response: {error}") + })?; + if output.session_id != session_id { + return Err( + "subjektiv Session attribution response changed session identity".to_string(), + ); + } + Ok(()) + } + async fn request_consolidation(&self) { let audit = WorkerAuditBase::new( memory::audit::AuditWorker::MemoryConsolidation, @@ -668,13 +839,19 @@ fn hook_internal(error: impl std::fmt::Display) -> HookError { fn extract_pointer( capture: &CommittedSessionCapture, + domain: &str, ) -> Result, HookError> { - let pointer = memory::extract::fold_pointer(&capture.extensions); + let pointer = capture + .extensions + .iter() + .rev() + .find(|(candidate, _)| candidate == domain) + .and_then(|(_, payload)| serde_json::from_value(payload.clone()).ok()); if pointer.is_none() && capture .extensions .iter() - .any(|(domain, _)| domain == extract::EXTRACT_DOMAIN) + .any(|(candidate, _)| candidate == domain) { return Err(hook_internal( "latest committed Memory extraction pointer is malformed", @@ -732,6 +909,7 @@ struct WorkerAuditBase { trigger: memory::audit::AuditTrigger, memory_settings: Option, model: Option, + persist_legacy_backend: bool, } impl WorkerAuditBase { @@ -746,9 +924,15 @@ impl WorkerAuditBase { trigger, memory_settings: None, model, + persist_legacy_backend: true, } } + fn without_legacy_backend(mut self) -> Self { + self.persist_legacy_backend = false; + self + } + fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self { self.memory_settings = config @@ -784,15 +968,19 @@ impl WorkerAuditBase { extract, consolidation, }; - let _ = workspace_client - .execute_memory_backend_operation(memory::backend::MemoryBackendOperation::AppendAudit( - memory::backend::MemoryAppendAuditOperation { - event: memory::audit::AuditEvent::new( - memory::audit::AuditPayload::WorkerLifecycle(payload), + if self.persist_legacy_backend { + let _ = workspace_client + .execute_memory_backend_operation( + memory::backend::MemoryBackendOperation::AppendAudit( + memory::backend::MemoryAppendAuditOperation { + event: memory::audit::AuditEvent::new( + memory::audit::AuditPayload::WorkerLifecycle(payload), + ), + }, ), - }, - )) - .await; + ) + .await; + } if let Some(tx) = event_tx { let _ = tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent { worker: self.worker.label().to_string(), @@ -859,6 +1047,8 @@ mod tests { &self, request: crate::worker::WorkspaceRequest, ) -> Result { + let is_subjektiv_stage = request.path.ends_with("/subjektiv/staging"); + let is_subjektiv_session = request.path.ends_with("/subjektiv/sessions"); let is_stage_candidate = request .body .as_deref() @@ -868,6 +1058,25 @@ mod tests { .as_deref() .is_some_and(|body| body.contains("append_audit")); self.requests.lock().unwrap().push(request); + if is_subjektiv_session { + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string(&server_api::SubjektivRecordSessionResponse { + subject_id: "subject-1".to_string(), + session_id: "session-1".to_string(), + }) + .unwrap(), + }); + } + if is_subjektiv_stage { + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse { + staging_id: "subject-candidate-1".to_string(), + }) + .unwrap(), + }); + } if is_stage_candidate { return Ok(crate::worker::WorkspaceResponse { status: 200, @@ -1072,6 +1281,7 @@ permission = "write" }); MemoryLifecycleTask { config: test_config(), + target: ExtractionTarget::WorkspaceMemory, capture: capture_handle, extensions, workspace_client, @@ -1122,6 +1332,30 @@ permission = "write" registry.shutdown().await.unwrap(); } + #[test] + fn disabled_subjektiv_feature_installs_nothing_without_workspace_side_effects() { + let capture = CommittedSessionCaptureHandle::new(|| { + panic!("disabled subjektiv must not capture a Session") + }); + let extensions = SessionExtensionHandle::new(|_, _, _| { + panic!("disabled subjektiv must not write a pointer") + }); + let feature = SubjektivLifecycleFeature::from_resolved_config( + true, + manifest::ResolvedSubjektivFeatureConfig::default(), + capture, + extensions, + crate::worker::marker_workspace_client(None, "disabled"), + test_manifest(), + Box::new(ScriptClient::new(Vec::new())), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + WorkerWorkspaceContext::no_workspace(), + None, + ) + .unwrap(); + assert!(feature.is_none()); + } + #[test] fn extraction_manifest_applies_memory_model_and_reasoning_overrides() { use agen::llm_client::capability::{ReasoningControl, ReasoningEffort}; @@ -1236,6 +1470,136 @@ permission = "write" ); } + #[tokio::test] + async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() { + let source = capture(2, 250); + let entry_ref = + SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone()) + .overview()[0] + .id + .to_string(); + let client = ScriptClient::new(vec![ + stage_candidate_events("stage-1", &entry_ref), + finish_events("finish-1", 1), + completed_events(), + ]); + let extension_writes = Arc::new(Mutex::new(Vec::new())); + let (event_tx, _) = broadcast::channel(64); + let workspace_client = Arc::new(RecordingWorkspaceClient::default()); + let mut task = test_task( + source, + Box::new(client), + Arc::clone(&extension_writes), + event_tx, + workspace_client.clone(), + ); + task.target = ExtractionTarget::Subjektiv; + task.config.profile.consolidation.request_enabled = false; + run_background_task(task).await; + + let writes = extension_writes.lock().unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN); + let pointer: memory::ExtractPointerPayload = + serde_json::from_value(writes[0].2.clone()).unwrap(); + assert_eq!(pointer.staging_id, "subject-candidate-1"); + let requests = workspace_client.requests.lock().unwrap(); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/subjektiv/staging")) + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/subjektiv/sessions")) + .count(), + 1 + ); + assert!(requests.iter().all(|request| { + request + .body + .as_deref() + .is_none_or(|body| !body.contains("append_audit") && body != "{\"force\":false}") + })); + } + + #[tokio::test] + async fn empty_subjektiv_extraction_records_session_and_commits_pointer_without_candidate() { + let client = ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]); + let extension_writes = Arc::new(Mutex::new(Vec::new())); + let (event_tx, _) = broadcast::channel(32); + let workspace_client = Arc::new(RecordingWorkspaceClient::default()); + let mut task = test_task( + capture(2, 250), + Box::new(client), + Arc::clone(&extension_writes), + event_tx, + workspace_client.clone(), + ); + task.target = ExtractionTarget::Subjektiv; + task.config.profile.consolidation.request_enabled = false; + run_background_task(task).await; + + let writes = extension_writes.lock().unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN); + let pointer: memory::ExtractPointerPayload = + serde_json::from_value(writes[0].2.clone()).unwrap(); + assert!(pointer.staging_id.is_empty()); + let requests = workspace_client.requests.lock().unwrap(); + assert!( + !requests + .iter() + .any(|request| request.path.ends_with("/subjektiv/staging")) + ); + assert!( + requests + .iter() + .any(|request| request.path.ends_with("/subjektiv/sessions")) + ); + } + + #[tokio::test] + async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() { + let source = capture(2, 250); + let entry_ref = + SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone()) + .overview()[0] + .id + .to_string(); + let client = ScriptClient::new(vec![stage_candidate_events("stage-1", &entry_ref)]); + let extension_writes = Arc::new(Mutex::new(Vec::new())); + let (event_tx, _) = broadcast::channel(32); + let workspace_client = Arc::new(RecordingWorkspaceClient::default()); + let mut task = test_task( + source, + Box::new(client), + Arc::clone(&extension_writes), + event_tx, + workspace_client.clone(), + ); + task.target = ExtractionTarget::Subjektiv; + task.config.profile.consolidation.request_enabled = false; + run_background_task(task).await; + + assert!(extension_writes.lock().unwrap().is_empty()); + let requests = workspace_client.requests.lock().unwrap(); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/subjektiv/staging")) + .count(), + 1, + "the immutable partial candidate remains staged for retry/deduplication" + ); + assert!(!requests + .iter() + .any(|request| request.path.ends_with("/subjektiv/sessions"))); + } + #[tokio::test] async fn failed_extraction_emits_failure_event_and_durable_audit_without_pointer() { let client = ScriptClient::new(Vec::new()); @@ -1562,7 +1926,10 @@ permission = "write" serde_json::to_value(&latest).unwrap(), ), ]; - assert_eq!(extract_pointer(&capture).unwrap(), Some(latest)); + assert_eq!( + extract_pointer(&capture, extract::EXTRACT_DOMAIN).unwrap(), + Some(latest) + ); } #[test] @@ -1583,7 +1950,7 @@ permission = "write" serde_json::json!({"invalid": true}), ), ]; - assert!(extract_pointer(&capture).is_err()); + assert!(extract_pointer(&capture, extract::EXTRACT_DOMAIN).is_err()); } #[test] diff --git a/crates/worker/src/feature/builtin/memory_staging_output.rs b/crates/worker/src/feature/builtin/memory_staging_output.rs index 4ccc90afe..2b9b7a09b 100644 --- a/crates/worker/src/feature/builtin/memory_staging_output.rs +++ b/crates/worker/src/feature/builtin/memory_staging_output.rs @@ -33,10 +33,17 @@ pub(crate) struct MemoryStagingOutputState { workspace_client: Arc, source: SourceRef, extract_run_id: String, + destination: MemoryStagingDestination, staged: Arc>>, finished: Arc>>, } +#[derive(Clone)] +enum MemoryStagingDestination { + WorkspaceMemory, + Subjektiv { session_id: String }, +} + impl MemoryStagingOutputState { pub(crate) fn new( view: SessionCapture, @@ -49,6 +56,25 @@ impl MemoryStagingOutputState { workspace_client, source, extract_run_id, + destination: MemoryStagingDestination::WorkspaceMemory, + staged: Arc::new(Mutex::new(Vec::new())), + finished: Arc::new(Mutex::new(None)), + } + } + + pub(crate) fn new_subjektiv( + view: SessionCapture, + workspace_client: Arc, + source: SourceRef, + extract_run_id: String, + session_id: String, + ) -> Self { + Self { + view: Arc::new(view), + workspace_client, + source, + extract_run_id, + destination: MemoryStagingDestination::Subjektiv { session_id }, staged: Arc::new(Mutex::new(Vec::new())), finished: Arc::new(Mutex::new(None)), } @@ -201,34 +227,72 @@ impl Tool for StageMemoryCandidateTool { staleness: params.staleness, evidence_ids: params.entry_refs, }; - let result = self - .state - .workspace_client - .execute_memory_backend_operation(MemoryBackendOperation::StageCandidate( - MemoryStageCandidateOperation { - source: self.state.source.clone(), - extract_run_id: self.state.extract_run_id.clone(), - candidate, - evidence, - source_refs, - }, - )) - .await - .map_err(map_memory_stage_error)?; - let staging_ids = match result { - MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => { - output.staging_ids + let operation = MemoryStageCandidateOperation { + source: self.state.source.clone(), + extract_run_id: self.state.extract_run_id.clone(), + candidate, + evidence, + source_refs, + }; + let staging_ids = match &self.state.destination { + MemoryStagingDestination::WorkspaceMemory => { + let result = self + .state + .workspace_client + .execute_memory_backend_operation(MemoryBackendOperation::StageCandidate( + operation, + )) + .await + .map_err(map_memory_stage_error)?; + match result { + MemoryBackendOperationResult::StagingWritten(output) + if output.staging_count == 1 => + { + output.staging_ids + } + MemoryBackendOperationResult::StagingWritten(output) => { + return Err(ToolError::ExecutionFailed(format!( + "StageMemoryCandidate expected one staging record, backend wrote {}", + output.staging_count + ))); + } + other => { + return Err(ToolError::ExecutionFailed(format!( + "unexpected Memory backend result for StageMemoryCandidate: {other:?}" + ))); + } + } } - MemoryBackendOperationResult::StagingWritten(output) => { - return Err(ToolError::ExecutionFailed(format!( - "StageMemoryCandidate expected one staging record, backend wrote {}", - output.staging_count - ))); - } - other => { - return Err(ToolError::ExecutionFailed(format!( - "unexpected Memory backend result for StageMemoryCandidate: {other:?}" - ))); + MemoryStagingDestination::Subjektiv { session_id } => { + let response = self + .state + .workspace_client + .execute_server_operation( + crate::worker::WorkspaceServerOperation::SubjektivStageCandidate( + server_api::SubjektivStageCandidateRequest { + session_id: session_id.clone(), + operation, + }, + ), + ) + .map_err(|error| { + ToolError::ExecutionFailed(format!( + "write subjektiv staging failed: {error}" + )) + })?; + if !response.is_success() { + return Err(ToolError::ExecutionFailed(format!( + "write subjektiv staging returned HTTP {}: {}", + response.status, response.body + ))); + } + let output: server_api::SubjektivStageCandidateResponse = + serde_json::from_str(&response.body).map_err(|error| { + ToolError::ExecutionFailed(format!( + "decode subjektiv staging response: {error}" + )) + })?; + vec![output.staging_id] } }; let staging_id = staging_ids.into_iter().next().ok_or_else(|| { @@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { + use std::sync::Mutex; + use agen::Item; use super::*; + #[derive(Debug, Default)] + struct RecordingSubjektivClient { + requests: Mutex>, + } + + impl WorkspaceClient for RecordingSubjektivClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-1") + } + + fn kind(&self) -> &str { + "subjektiv-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse { + staging_id: "subject-candidate-1".to_string(), + }) + .unwrap(), + }) + } + } + fn state() -> MemoryStagingOutputState { MemoryStagingOutputState::new( SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]), @@ -503,6 +602,39 @@ mod tests { assert!(format!("{error:?}").contains("exclusively HumanInput evidence")); } + #[tokio::test] + async fn subjektiv_destination_sends_committed_session_without_subject_scope() { + let client = Arc::new(RecordingSubjektivClient::default()); + let state = MemoryStagingOutputState::new_subjektiv( + SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]), + client.clone(), + SourceRef { + segment_id: "segment-1".to_string(), + range: [0, 0], + }, + "run-1".to_string(), + "session-committed".to_string(), + ); + let tool = StageMemoryCandidateTool { + state: state.clone(), + }; + tool.execute( + r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#, + agen::tool::ToolExecutionContext::direct(), + ) + .await + .unwrap(); + + assert_eq!(state.staged(), vec!["subject-candidate-1"]); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].path.ends_with("/subjektiv/staging")); + let body = requests[0].body.as_deref().unwrap(); + assert!(body.contains("session-committed")); + assert!(body.contains("why_useful")); + assert!(!body.contains("subject_id")); + } + #[tokio::test] async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() { let tool = StageMemoryCandidateTool { state: state() }; diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 4313a184f..c44521d75 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -624,6 +624,8 @@ pub enum WorkspaceServerOperation { }, WorkerObservationSessions, WorkerObservationCapture(server_api::WorkerObservationSubjectRef), + SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), + SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), } fn workspace_server_json_request( @@ -702,6 +704,12 @@ fn workspace_server_operation_request( WorkspaceServerOperation::WorkerObservationCapture(request) => { workspace_server_json_request(format!("{base}/worker-observation/session"), &request) } + WorkspaceServerOperation::SubjektivStageCandidate(request) => { + workspace_server_json_request(format!("{base}/subjektiv/staging"), &request) + } + WorkspaceServerOperation::SubjektivRecordSession(request) => { + workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request) + } } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 08e09b32d..d76521768 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1388,6 +1388,7 @@ pub struct WorkspaceApi { config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, feature_storage: crate::WorkspaceFeatureStorage, + subjektiv_registration: crate::RegisteredFeature, authority: SqliteWorkspaceAuthority, _worker_projection_shutdown: Arc, runtime: Arc, @@ -3215,6 +3216,10 @@ impl WorkspaceApi { .map_err(|error| { Error::Store(format!("failed to initialize Feature storage: {error}")) })?; + let subjektiv_registration = crate::subjektiv::SubjektivStore::register(&feature_storage) + .map_err(|error| { + Error::Store(format!("failed to register subjektiv storage: {error}")) + })?; let api = Self { config_store, repository_secrets, @@ -3223,6 +3228,7 @@ impl WorkspaceApi { prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache::default(), feature_storage, + subjektiv_registration, authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -5432,6 +5438,24 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::memory_consolidation( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_create( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_subject_get( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_subject_retire( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_subject_worker_start( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_stage_candidate( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_record_session( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -7006,6 +7030,119 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_create( + &self, + workspace_id: String, + request: server_api::SubjektivSubjectCreateRequest, + ) -> std::result::Result + { + scoped_create_subjektiv_subject( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|(_, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_get( + &self, + workspace_id: String, + subject_id: String, + ) -> std::result::Result + { + scoped_get_subjektiv_subject( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_retire( + &self, + workspace_id: String, + subject_id: String, + ) -> std::result::Result + { + scoped_retire_subjektiv_subject( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_worker_start( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + subject_id: String, + request: server_api::SubjektivSubjectWorkerStartRequest, + ) -> std::result::Result { + scoped_start_subjektiv_subject_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_stage_candidate( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::SubjektivStageCandidateRequest, + ) -> std::result::Result< + server_api::SubjektivStageCandidateResponse, + server_api::RepositoryApiError, + > { + scoped_stage_subjektiv_candidate( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_record_session( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::SubjektivRecordSessionRequest, + ) -> std::result::Result< + server_api::SubjektivRecordSessionResponse, + server_api::RepositoryApiError, + > { + scoped_record_subjektiv_session( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn skill_list( &self, workspace_id: String, @@ -10217,6 +10354,12 @@ struct ScopedWorkspacePath { workspace_id: String, } +#[derive(Debug, Clone, Deserialize)] +struct ScopedSubjektivSubjectPath { + workspace_id: String, + subject_id: String, +} + #[derive(Debug, Deserialize)] struct ScopedFlowPath { workspace_id: String, @@ -16352,6 +16495,255 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } +const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; + +fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { + crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) + .map_err(|error| ApiError::from(Error::Store(error.to_string()))) +} + +fn subjektiv_subject_id_from_singleton_key(key: &str) -> ApiResult<&str> { + key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) + .filter(|subject_id| { + !subject_id.is_empty() + && !subject_id.chars().any(char::is_control) + && subject_id.trim() == *subject_id + }) + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "current Worker is not connected to a subjektiv subject".to_string(), + ) + .into() + }) +} + +fn subjektiv_singleton_key(subject_id: &str) -> ApiResult { + if subject_id.trim().is_empty() || subject_id.chars().any(char::is_control) { + return Err(Error::InvalidInput("subjektiv subject id is invalid".to_string()).into()); + } + let key = format!("{SUBJEKTIV_SINGLETON_PREFIX}{subject_id}"); + crate::store::validate_worker_singleton_key(&key)?; + Ok(key) +} + +fn subjektiv_session_attribution( + api: &WorkspaceApi, + workspace_id: &str, + context: &server_api::ServerRequestContext, + session_id: &str, +) -> ApiResult { + let source = context.runtime_source.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "subjektiv extraction requires authenticated Runtime-owned source authority" + .to_string(), + ) + })?; + let worker_id = source.worker_id.as_deref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "subjektiv extraction requires Runtime-bound Worker identity".to_string(), + ) + })?; + let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); + let lease = api + .store + .require_current_worker_singleton_owner(workspace_id, &worker)? + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "current Worker has no keyed singleton ownership".to_string(), + ) + })?; + let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?; + let session_id = session_id.trim(); + if session_id.is_empty() || session_id.chars().any(char::is_control) { + return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into()); + } + crate::subjektiv::SubjectSessionAttribution::new( + subject_id, + worker.runtime_id, + worker.worker_id, + session_id, + ) + .map_err(|error| Error::InvalidInput(error.to_string()).into()) +} + +fn subjektiv_subject_response( + api: &WorkspaceApi, + subject: crate::subjektiv::SubjectRecord, +) -> server_api::SubjektivSubjectResponse { + let current_worker = subjektiv_singleton_key(&subject.id) + .ok() + .and_then(|key| { + api.store + .current_worker_singleton_owner(&api.config.workspace_id, &key) + .ok() + .flatten() + }) + .and_then(|lease| api.runtime.worker(&lease.worker).ok()) + .map(worker_launch_worker_summary); + server_api::SubjektivSubjectResponse { + id: subject.id, + role: subject.role.as_str().to_string(), + state: match subject.state { + crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, + crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, + }, + store_revision: subject.store_revision, + created_at: subject.created_at, + updated_at: subject.updated_at, + current_worker, + } +} + +async fn scoped_create_subjektiv_subject( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + validate_workspace_scope(&api, &path.workspace_id)?; + let role = crate::subjektiv::SubjectRole::new(request.role) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .create_subject(role) + .map_err(|error| Error::Store(error.to_string()))?; + Ok(( + StatusCode::CREATED, + Json(subjektiv_subject_response(&api, subject)), + )) +} + +async fn scoped_get_subjektiv_subject( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .subject(&path.subject_id) + .map_err(|error| Error::Store(error.to_string()))? + .ok_or_else(|| { + Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id)) + })?; + Ok(Json(subjektiv_subject_response(&api, subject))) +} + +async fn scoped_retire_subjektiv_subject( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .retire_subject(&path.subject_id) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + // Retirement deliberately does not stop or remove the current Worker. The + // Worker lease and subject lifecycle are independent authorities. + Ok(Json(subjektiv_subject_response(&api, subject))) +} + +async fn scoped_start_subjektiv_subject_worker( + State(api): State, + AxumPath(path): AxumPath, + headers: HeaderMap, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .subject(&path.subject_id) + .map_err(|error| Error::Store(error.to_string()))? + .ok_or_else(|| { + Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id)) + })?; + if subject.state != crate::subjektiv::SubjectState::Active { + return Err(Error::InvalidInput(format!( + "subjektiv subject `{}` is retired", + path.subject_id + )) + .into()); + } + let mut worker = request.worker; + if worker.singleton_key.is_some() { + return Err(Error::InvalidInput( + "subjektiv subject Worker start owns singleton selection; singleton_key must be omitted" + .to_string(), + ) + .into()); + } + worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?); + create_workspace_worker(State(api), headers, Json(worker)).await +} + +async fn scoped_stage_subjektiv_candidate( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(mut request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let attribution = + subjektiv_session_attribution(&api, &path.workspace_id, &context, &request.session_id)?; + let subject_id = attribution.subject_id.clone(); + let session_id = attribution.session_id.as_str(); + for source_ref in &mut request.operation.source_refs { + if source_ref + .session_id + .as_deref() + .is_some_and(|value| value != session_id) + { + return Err(Error::InvalidInput( + "candidate source_ref belongs to a different Session".to_string(), + ) + .into()); + } + source_ref.session_id = Some(session_id.to_string()); + } + if matches!( + request.operation.candidate.kind, + memory::extract::CandidateKind::Preference + ) && request.operation.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) { + return Err(Error::InvalidInput( + "preference candidates require exclusively HumanInput evidence".to_string(), + ) + .into()); + } + let candidate_id = Uuid::now_v7().to_string(); + let record = memory::extract::StagingRecord::from_candidate( + candidate_id, + request.operation.extract_run_id, + request.operation.source, + request.operation.candidate, + request.operation.evidence, + request.operation.source_refs, + ); + let record = crate::subjektiv::SubjectStagingRecord::attach(&subject_id, record); + let (staged, _) = open_subjektiv_store(&api)? + .stage_candidate_with_attribution(record, attribution) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + Ok(Json(server_api::SubjektivStageCandidateResponse { + staging_id: staged.id, + })) +} + +async fn scoped_record_subjektiv_session( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let attribution = + subjektiv_session_attribution(&api, &path.workspace_id, &context, &request.session_id)?; + let attribution = open_subjektiv_store(&api)? + .record_session_attribution(attribution) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + Ok(Json(server_api::SubjektivRecordSessionResponse { + subject_id: attribution.subject_id, + session_id: attribution.session_id, + })) +} + const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; @@ -33523,6 +33915,120 @@ mod tests { ); } + #[tokio::test] + async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let subject = open_subjektiv_store(&api) + .unwrap() + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let request = server_api::SubjektivSubjectWorkerStartRequest { + worker: CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Subject Worker".to_string(), + singleton_key: None, + profile: Some("builtin:companion".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + control_operation_id: None, + }, + }; + let Json(created) = scoped_start_subjektiv_subject_worker( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: subject.id.clone(), + }), + HeaderMap::new(), + Json(request), + ) + .await + .unwrap(); + assert_eq!( + created.worker.singleton_key.as_deref(), + Some(format!("subjektiv:{}", subject.id).as_str()) + ); + let context = server_api::ServerRequestContext { + actor: None, + worker_source: None, + runtime_source: Some(server_api::ServerRuntimeSource { + runtime_id: created.runtime_id.clone(), + worker_id: Some(created.worker_id.clone()), + }), + origin: None, + transport_headers: Vec::new(), + }; + let operation = memory::backend::MemoryStageCandidateOperation { + source: memory::schema::SourceRef { + segment_id: "segment-1".to_string(), + range: [0, 0], + }, + extract_run_id: "extract-1".to_string(), + candidate: memory::extract::ExtractedCandidate { + kind: memory::extract::CandidateKind::Decision, + claim: "Keep subject scope host-derived".to_string(), + why_useful: "Prevents cross-subject writes".to_string(), + staleness: None, + evidence_ids: vec!["E00000000".to_string()], + }, + evidence: vec![memory::extract::StagingEvidence { + id: "E00000000".to_string(), + kind: memory::schema::EvidenceKind::new(memory::schema::EvidenceKind::MESSAGE), + entry_range: Some([0, 0]), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::HumanInput, + account_id: None, + workspace_id: None, + runtime_id: None, + worker_id: None, + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }), + excerpt: Some("Keep subject scope host-derived".to_string()), + summary: Some("Scope decision".to_string()), + }], + source_refs: vec![memory::schema::SourceEvidenceRef { + segment_id: Some("segment-1".to_string()), + entry_range: Some([0, 0]), + evidence_id: Some("E00000000".to_string()), + evidence_kind: Some(memory::schema::EvidenceKind::new( + memory::schema::EvidenceKind::MESSAGE, + )), + ..Default::default() + }], + }; + let Json(staged) = scoped_stage_subjektiv_candidate( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context, + Json(server_api::SubjektivStageCandidateRequest { + session_id: "session-1".to_string(), + operation, + }), + ) + .await + .unwrap(); + let store = open_subjektiv_store(&api).unwrap(); + let candidate = store + .staging_candidate(&subject.id, &staged.staging_id) + .unwrap() + .unwrap(); + assert_eq!(candidate.subject_id, subject.id); + assert_eq!( + candidate.source_refs[0].session_id.as_deref(), + Some("session-1") + ); + let attribution = store.session_attribution("session-1").unwrap().unwrap(); + assert_eq!(attribution.subject_id, subject.id); + assert_eq!(attribution.worker_id, created.worker_id); + } + #[tokio::test] async fn singleton_retry_disconnect_replacement_and_restore_are_generation_fenced() { let workspace = tempfile::tempdir().unwrap(); @@ -48089,6 +48595,21 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); serde_json::from_slice(&bytes).unwrap() } + #[test] + fn subjektiv_singleton_scope_is_explicit_and_opaque() { + assert_eq!( + subjektiv_subject_id_from_singleton_key("subjektiv:subject-42").unwrap(), + "subject-42" + ); + assert!(subjektiv_subject_id_from_singleton_key("workspace-orchestrator").is_err()); + assert!(subjektiv_subject_id_from_singleton_key("subjektiv:").is_err()); + assert!(subjektiv_subject_id_from_singleton_key("subjektiv: subject-42").is_err()); + assert_eq!( + subjektiv_singleton_key("subject-42").unwrap(), + "subjektiv:subject-42" + ); + } + fn write_ticket( database_path: &Path, workspace_id: &str, diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 6c95c3188..639b12a66 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -41,6 +41,16 @@ pub enum SubjektivError { CandidateConflict(String), #[error("staging candidate `{0}` is already resolved")] CandidateResolved(String), + #[error( + "session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`" + )] + SessionSubjectConflict { + session_id: String, + existing_subject_id: String, + requested_subject_id: String, + }, + #[error("session `{0}` already has different historical attribution")] + SessionAttributionConflict(String), #[error("memory `{0}` was not found for this subject")] MemoryNotFound(String), #[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")] @@ -117,6 +127,40 @@ pub struct SubjectRecord { pub updated_at: String, } +/// Immutable host-recorded history that attributes one durable Session to the +/// subject and Worker that produced it. This is archival provenance only: it is +/// deliberately not a current-Worker link or execution ownership record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectSessionAttribution { + pub schema_version: u32, + pub subject_id: String, + pub runtime_id: String, + pub worker_id: String, + pub session_id: String, + pub attributed_at: String, +} + +impl SubjectSessionAttribution { + pub fn new( + subject_id: impl Into, + runtime_id: impl Into, + worker_id: impl Into, + session_id: impl Into, + ) -> Result { + let record = Self { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + subject_id: subject_id.into(), + runtime_id: runtime_id.into(), + worker_id: worker_id.into(), + session_id: session_id.into(), + attributed_at: now(), + }; + validate_session_attribution(&record)?; + Ok(record) + } +} + /// The existing extraction schema with only host-owned subject scope and /// persistence time added. Model input still contains neither field. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -602,11 +646,45 @@ END; Ok(()) } -static MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( - 1, - "create subjektiv subject memory store", - create_schema, -)]; +fn add_subject_session_attribution( + transaction: &Transaction<'_>, +) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE subject_session_attributions ( + session_id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + record_json TEXT NOT NULL, + attributed_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX subject_session_attributions_by_subject +ON subject_session_attributions(subject_id, attributed_at, session_id); + +CREATE TRIGGER subject_session_attributions_no_update +BEFORE UPDATE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); +END; +CREATE TRIGGER subject_session_attributions_no_delete +BEFORE DELETE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); +END; +"#, + )?; + Ok(()) +} + +static MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), +]; pub const REGISTRATION: FeatureRegistration = FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS); @@ -740,33 +818,89 @@ impl SubjektivStore { let raw = serde_json::to_string(&record)?; self.database.try_transaction(|transaction| { require_active_subject(transaction, &record.subject_id)?; - let existing = transaction + write_staging_candidate(transaction, self.workspace_id(), record, raw) + }) + } + + /// Atomically records immutable host attribution for the committed Session + /// and stages its extracted candidate. Exact retries return the first stored + /// records, including their original host timestamps. + pub fn stage_candidate_with_attribution( + &self, + mut record: SubjectStagingRecord, + attribution: SubjectSessionAttribution, + ) -> Result<(SubjectStagingRecord, SubjectSessionAttribution)> { + validate_session_attribution(&attribution)?; + if record.subject_id != attribution.subject_id { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: record.subject_id, + reference: format!( + "session {} is attributed to subject {}", + attribution.session_id, attribution.subject_id + ), + }); + } + attach_session_to_candidate(&mut record, &attribution.session_id)?; + validate_staging_record(self.workspace_id(), &record)?; + let candidate_raw = serde_json::to_string(&record)?; + let attribution_raw = serde_json::to_string(&attribution)?; + + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &record.subject_id)?; + let attribution = write_session_attribution(transaction, attribution, attribution_raw)?; + let candidate = + write_staging_candidate(transaction, self.workspace_id(), record, candidate_raw)?; + Ok((candidate, attribution)) + }) + } + + pub fn record_session_attribution( + &self, + attribution: SubjectSessionAttribution, + ) -> Result { + validate_session_attribution(&attribution)?; + let raw = serde_json::to_string(&attribution)?; + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &attribution.subject_id)?; + write_session_attribution(transaction, attribution, raw) + }) + } + + pub fn session_attribution( + &self, + session_id: &str, + ) -> Result> { + validate_label("session id", session_id)?; + self.database.try_with_connection(|connection| { + let raw = connection .query_row( - "SELECT record_json FROM staging_records - WHERE subject_id = ?1 AND candidate_id = ?2", - params![record.subject_id, record.id], + "SELECT record_json FROM subject_session_attributions + WHERE session_id = ?1", + [session_id], |row| row.get::<_, String>(0), ) .optional()?; - if let Some(existing) = existing { - if equivalent_staging_payload(&existing, &raw)? { - return parse_staging(self.workspace_id(), &existing); - } - return Err(SubjektivError::CandidateConflict(record.id.clone())); - } - transaction.execute( - "INSERT INTO staging_records ( - subject_id, candidate_id, kind, record_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - record.subject_id, - record.id, - candidate_kind_name(&record.kind), - raw, - record.created_at - ], + raw.map(|raw| parse_session_attribution(&raw)).transpose() + }) + } + + pub fn subject_session_attributions( + &self, + subject_id: &str, + ) -> Result> { + validate_label("subject id", subject_id)?; + self.database.try_with_connection(|connection| { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 + ORDER BY attributed_at ASC, session_id ASC", )?; - Ok(record) + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_session_attribution(&row?)?); + } + Ok(records) }) } @@ -1228,6 +1362,85 @@ fn write_memory_revision( Ok(record) } +fn write_staging_candidate( + transaction: &Transaction<'_>, + workspace_id: &str, + record: SubjectStagingRecord, + raw: String, +) -> Result { + let existing = transaction + .query_row( + "SELECT record_json FROM staging_records + WHERE subject_id = ?1 AND candidate_id = ?2", + params![record.subject_id, record.id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(existing) = existing { + if equivalent_staging_payload(&existing, &raw)? { + return parse_staging(workspace_id, &existing); + } + return Err(SubjektivError::CandidateConflict(record.id.clone())); + } + transaction.execute( + "INSERT INTO staging_records ( + subject_id, candidate_id, kind, record_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + record.subject_id, + record.id, + candidate_kind_name(&record.kind), + raw, + record.created_at + ], + )?; + Ok(record) +} + +fn write_session_attribution( + transaction: &Transaction<'_>, + record: SubjectSessionAttribution, + raw: String, +) -> Result { + let existing = transaction + .query_row( + "SELECT record_json FROM subject_session_attributions WHERE session_id = ?1", + [&record.session_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(existing) = existing { + let existing = parse_session_attribution(&existing)?; + if existing.subject_id != record.subject_id { + return Err(SubjektivError::SessionSubjectConflict { + session_id: record.session_id, + existing_subject_id: existing.subject_id, + requested_subject_id: record.subject_id, + }); + } + if equivalent_session_attribution(&existing, &record) { + return Ok(existing); + } + return Err(SubjektivError::SessionAttributionConflict( + record.session_id, + )); + } + transaction.execute( + "INSERT INTO subject_session_attributions ( + session_id, subject_id, runtime_id, worker_id, record_json, attributed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + record.session_id, + record.subject_id, + record.runtime_id, + record.worker_id, + raw, + record.attributed_at + ], + )?; + Ok(record) +} + fn insert_resolution( transaction: &Transaction<'_>, subject_id: &str, @@ -1436,6 +1649,48 @@ fn validate_memory_refs( Ok(()) } +fn validate_session_attribution(record: &SubjectSessionAttribution) -> Result<()> { + validate_schema_version(record.schema_version, "subject Session attribution")?; + validate_label("subject id", &record.subject_id)?; + validate_label("runtime id", &record.runtime_id)?; + validate_label("worker id", &record.worker_id)?; + validate_label("session id", &record.session_id)?; + validate_label("Session attribution timestamp", &record.attributed_at)?; + chrono::DateTime::parse_from_rfc3339(&record.attributed_at).map_err(|error| { + SubjektivError::InvalidRecord(format!( + "Session attribution timestamp is not RFC 3339: {error}" + )) + })?; + Ok(()) +} + +fn attach_session_to_candidate(record: &mut SubjectStagingRecord, session_id: &str) -> Result<()> { + if record.source_refs.is_empty() { + record.source_refs.push(SourceEvidenceRef { + session_id: Some(session_id.to_string()), + segment_id: Some(record.source.segment_id.clone()), + entry_range: Some(record.source.range), + ..SourceEvidenceRef::default() + }); + return Ok(()); + } + for source in &mut record.source_refs { + match source.session_id.as_deref() { + Some(existing) if existing != session_id => { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: record.subject_id.clone(), + reference: format!( + "candidate source Session `{existing}` does not match attributed Session `{session_id}`" + ), + }); + } + Some(_) => {} + None => source.session_id = Some(session_id.to_string()), + } + } + Ok(()) +} + fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Result<()> { if record.schema_version != STAGING_SCHEMA_VERSION { return Err(SubjektivError::InvalidRecord(format!( @@ -1450,6 +1705,18 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> validate_nonempty("candidate claim", &record.claim)?; validate_nonempty("candidate why_useful", &record.why_useful)?; validate_range("candidate source", record.source.range)?; + if matches!(record.kind, CandidateKind::Preference) + && record.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) + { + return Err(SubjektivError::InvalidRecord( + "preference candidates require exclusively HumanInput evidence".to_string(), + )); + } let mut evidence_ids = HashSet::new(); for evidence in &record.evidence { @@ -1552,6 +1819,17 @@ fn parse_subject(raw: &str) -> Result { Ok(record) } +fn equivalent_session_attribution( + left: &SubjectSessionAttribution, + right: &SubjectSessionAttribution, +) -> bool { + left.schema_version == right.schema_version + && left.subject_id == right.subject_id + && left.runtime_id == right.runtime_id + && left.worker_id == right.worker_id + && left.session_id == right.session_id +} + fn equivalent_staging_payload(left: &str, right: &str) -> Result { let mut left = serde_json::from_str::(left)?; let mut right = serde_json::from_str::(right)?; @@ -1569,6 +1847,12 @@ fn equivalent_staging_payload(left: &str, right: &str) -> Result { Ok(left == right) } +fn parse_session_attribution(raw: &str) -> Result { + let record: SubjectSessionAttribution = serde_json::from_str(raw)?; + validate_session_attribution(&record)?; + Ok(record) +} + fn parse_staging(workspace_id: &str, raw: &str) -> Result { let record = parse_staging_without_workspace(raw)?; validate_staging_record(workspace_id, &record)?; @@ -1736,6 +2020,12 @@ mod tests { use super::*; use crate::FeatureStorage; + static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( + 1, + "create subjektiv subject memory store", + create_schema, + )]; + fn role() -> SubjectRole { SubjectRole::new("workspace_companion").unwrap() } @@ -1787,6 +2077,19 @@ mod tests { SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into()) } + fn attribution( + subject_id: &str, + runtime_id: &str, + worker_id: &str, + session_id: &str, + attributed_at: &str, + ) -> SubjectSessionAttribution { + let mut record = + SubjectSessionAttribution::new(subject_id, runtime_id, worker_id, session_id).unwrap(); + record.attributed_at = attributed_at.to_string(); + record + } + fn draft(claim: &str, reason: &str) -> MemoryDraft { MemoryDraft::active( CandidateKind::Constraint, @@ -1851,12 +2154,217 @@ mod tests { assert!(serde_json::from_str::(&raw.replace("active", "unknown")).is_err()); } + #[test] + fn v1_store_migrates_before_attributed_staging() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let subject_id = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V1_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + store.create_subject(role()).unwrap().id + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let (staged, stored_attribution) = store + .stage_candidate_with_attribution( + candidate(&subject_id, "candidate-after-migration", "workspace-a"), + attribution( + &subject_id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ), + ) + .unwrap(); + + assert_eq!(staged.subject_id, subject_id); + assert_eq!(stored_attribution.session_id, "session-1"); + assert_eq!( + store.session_attribution("session-1").unwrap().unwrap(), + stored_attribution + ); + } + + #[test] + fn attributed_staging_is_atomic_and_retry_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let first_attribution = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ); + let (first_candidate, first_attribution) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + first_attribution, + ) + .unwrap(); + + let mut retried_candidate = first_candidate.clone(); + retried_candidate.created_at = "2026-09-28T11:00:00.000Z".into(); + let retried_attribution = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T11:00:00.000Z", + ); + let (retried_candidate, retried_attribution) = store + .stage_candidate_with_attribution(retried_candidate, retried_attribution) + .unwrap(); + assert_eq!(retried_candidate.created_at, first_candidate.created_at); + assert_eq!(retried_attribution, first_attribution); + + let mut conflicting_candidate = candidate(&subject.id, "candidate-1", "workspace-a"); + conflicting_candidate.claim = "different content".into(); + conflicting_candidate.source_refs[0].session_id = Some("session-2".into()); + let result = store.stage_candidate_with_attribution( + conflicting_candidate, + attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-2", + "2026-09-28T12:00:00.000Z", + ), + ); + assert!(matches!( + result, + Err(SubjektivError::CandidateConflict(candidate_id)) + if candidate_id == "candidate-1" + )); + assert!(store.session_attribution("session-2").unwrap().is_none()); + } + + #[test] + fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + + store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ), + ) + .unwrap(); + let mut replacement_candidate = candidate(&subject.id, "candidate-2", "workspace-a"); + replacement_candidate.source_refs[0].session_id = None; + let (replacement_candidate, _) = store + .stage_candidate_with_attribution( + replacement_candidate, + attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-2", + "2026-09-28T11:00:00.000Z", + ), + ) + .unwrap(); + assert_eq!( + replacement_candidate.source_refs[0].session_id.as_deref(), + Some("session-2") + ); + + let history = store.subject_session_attributions(&subject.id).unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].worker_id, "worker-1"); + assert_eq!(history[1].worker_id, "worker-2"); + assert!(history.iter().all(|entry| entry.subject_id == subject.id)); + + let update = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "UPDATE subject_session_attributions SET worker_id = 'worker-other' + WHERE session_id = 'session-1'", + [], + )?; + Ok(()) + }); + assert!(update.is_err()); + let delete = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "DELETE FROM subject_session_attributions WHERE session_id = 'session-1'", + [], + )?; + Ok(()) + }); + assert!(delete.is_err()); + + let mut foreign_candidate = + candidate(&other_subject.id, "candidate-foreign", "workspace-a"); + foreign_candidate.source_refs[0].session_id = Some("session-1".into()); + let result = store.stage_candidate_with_attribution( + foreign_candidate, + attribution( + &other_subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T12:00:00.000Z", + ), + ); + assert!(matches!( + result, + Err(SubjektivError::SessionSubjectConflict { + session_id, + existing_subject_id, + requested_subject_id, + }) if session_id == "session-1" + && existing_subject_id == subject.id + && requested_subject_id == other_subject.id + )); + assert!( + store + .staging_candidate(&other_subject.id, "candidate-foreign") + .unwrap() + .is_none() + ); + } + #[test] fn malformed_nested_provenance_is_rejected_without_staging() { let temp = tempfile::tempdir().unwrap(); let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); let subject = store.create_subject(role()).unwrap(); + let mut non_human_preference = + candidate(&subject.id, "candidate-preference", "workspace-a"); + non_human_preference.kind = CandidateKind::Preference; + non_human_preference.evidence[0] + .origin + .as_mut() + .unwrap() + .kind = EvidenceOriginKind::ModelOutput; + assert!(matches!( + store.stage_candidate(non_human_preference), + Err(SubjektivError::InvalidRecord(message)) + if message.contains("HumanInput") + )); + let mut reversed = candidate(&subject.id, "candidate-reversed", "workspace-a"); reversed.evidence[0].entry_range = Some([12, 10]); assert!(matches!( diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 5d59a1e7c..50d9c6357 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -19,14 +19,17 @@ cutover of the existing Workspace Memory authority. Async callers must run these synchronous repository calls on a blocking executor. - The store contains no `current_worker_id` or equivalent current link. Yoi's - keyed singleton remains the sole authority for the current Worker. Historical - Worker/Session attribution is provenance and will be connected separately. + keyed singleton remains the sole authority for the current Worker. T-668 + connects Workers using the opaque `subjektiv:` key and records + immutable historical Worker/Session attribution as provenance; that history is + never consulted as a current Worker link. Registration is deliberately separate from open. Trusted Server construction registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its `RegisteredFeature` for each Workspace. Merely adding this module does not open -or create a subjektiv database; product enablement and old-Memory cutover are -later work. +or create a subjektiv database. T-668 opens it lazily for explicit subject +operations or an authenticated subject extraction write; leaving the Feature +disabled has no subject-store side effect. ## Versioned records @@ -120,3 +123,39 @@ online backup/restore includes the entire subjektiv database, including immutabl history and provenance. Workspace deletion and shutdown use the common lifecycle fences and close retained repository handles. Disabling subjektiv does not erase its database. + +## Extraction connection (T-668) + +`feature.subjektiv` is an explicit Worker profile boundary. It reuses the +committed-run threshold, restricted Internal extraction Worker, +`SessionEntryRef` evidence resolution, explicit finish, generation fence, and +success-only pointer progression from the established Memory lifecycle. Its +pointer domain is separate from legacy Workspace Memory, and a Worker is rejected +at installation when both automatic extraction destinations are enabled. Ordinary +SubWorkers and Reviewer SubWorkers have lifecycle Features disabled by their host; +only evidence actually committed into the parent Session can enter the parent's +capture. + +The staging request contains the existing model contract (`kind`, `claim`, +`why_useful`, optional `staleness`, and entry references after host resolution) +plus the committed parent `session_id`. It contains no subject id. The Server +derives subject scope from authenticated Runtime/Worker source proof and the +current keyed-singleton lease, then attaches the session id to every source +reference and records immutable historical `{subject, runtime, worker, session}` +provenance atomically with staging. A Session already attributed to another +subject is rejected. Preference candidates still require exclusively HumanInput +evidence. + +Subject retirement does not stop a Worker, and stopping/replacing a Worker does +not retire the subject. A replacement uses the same `subjektiv:` key; +retries therefore recover through Yoi's singleton authority without a second +current-worker mapping in this database. + +Candidates are immutable writes that occur before `FinishMemoryExtraction`. If a +run stages one or more candidates and then fails, is cancelled, or loses its +generation fence, those candidates remain recorded and the extraction pointer +does not advance. A retry may therefore produce semantically duplicate candidates; +subsequent consolidation must compare them using the existing candidate +identity/content boundary. The system must not claim that the partial writes were +rolled back or silently delete them. Subject recall, consolidation, and legacy +Memory migration remain separate work. From 6e293d57cdeed511029085065088757c27b3b7e5 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 06:18:02 +0900 Subject: [PATCH 02/31] test: cover subjektiv lifecycle boundaries --- crates/worker/src/controller.rs | 45 +++++++--- .../src/feature/builtin/memory_lifecycle.rs | 89 +++++++++++++++++-- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 0e4665d32..0da10dcf0 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1141,6 +1141,23 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } +fn validate_automatic_memory_extraction_targets( + memory: &manifest::MemoryFeatureProfileConfig, + subjektiv: &manifest::SubjektivFeatureProfileConfig, +) -> std::io::Result<()> { + if memory.enabled + && memory.extraction.enabled + && subjektiv.enabled + && subjektiv.extraction.enabled + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker", + )); + } + Ok(()) +} + /// Register the builtin file-manipulation tools, optional memory tools, /// and the Worker-orchestration tools (SubWorkerSpawn + comm) on the Worker's /// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to @@ -1252,16 +1269,7 @@ where } let memory_profile = &worker.manifest().feature.memory.profile; let subjektiv_profile = &worker.manifest().feature.subjektiv.profile; - if memory_profile.enabled - && memory_profile.extraction.enabled - && subjektiv_profile.enabled - && subjektiv_profile.extraction.enabled - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker", - )); - } + validate_automatic_memory_extraction_targets(memory_profile, subjektiv_profile)?; let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare( worker.manifest(), worker.workspace_client_handle(), @@ -3438,6 +3446,23 @@ mod tests { use tempfile::TempDir; use tokio::net::UnixListener; + #[test] + fn automatic_memory_extraction_destinations_are_mutually_exclusive() { + let mut memory = manifest::MemoryFeatureProfileConfig::default(); + memory.enabled = true; + memory.extraction.enabled = true; + let mut subjektiv = manifest::SubjektivFeatureProfileConfig::default(); + subjektiv.enabled = true; + subjektiv.extraction.enabled = true; + + let error = validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("cannot both be enabled")); + + subjektiv.extraction.enabled = false; + validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap(); + } + #[test] fn no_controller_parent_notification_uses_durable_pending_authority() { let temp = TempDir::new().unwrap(); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 6e3027737..39994a464 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -1356,6 +1356,43 @@ permission = "write" assert!(feature.is_none()); } + #[test] + fn child_host_suppresses_enabled_subjektiv_lifecycle_without_capture_side_effects() { + let capture = CommittedSessionCaptureHandle::new(|| { + panic!("a child Worker must not capture for an inherited subjektiv profile") + }); + let extensions = SessionExtensionHandle::new(|_, _, _| { + panic!("a child Worker must not write a subjektiv extraction pointer") + }); + let mut config = manifest::ResolvedSubjektivFeatureConfig::default(); + config.profile.enabled = true; + config.profile.extraction.enabled = true; + config.profile.extraction.threshold = Some(1); + config + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); + + let feature = SubjektivLifecycleFeature::from_resolved_config( + false, + config, + capture, + extensions, + crate::worker::marker_workspace_client(None, "child-disabled"), + test_manifest(), + Box::new(ScriptClient::new(Vec::new())), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + WorkerWorkspaceContext::no_workspace(), + None, + ) + .unwrap(); + + assert!(feature.is_none()); + } + #[test] fn extraction_manifest_applies_memory_model_and_reasoning_overrides() { use agen::llm_client::capability::{ReasoningControl, ReasoningEffort}; @@ -1595,9 +1632,11 @@ permission = "write" 1, "the immutable partial candidate remains staged for retry/deduplication" ); - assert!(!requests - .iter() - .any(|request| request.path.ends_with("/subjektiv/sessions"))); + assert!( + !requests + .iter() + .any(|request| request.path.ends_with("/subjektiv/sessions")) + ); } #[tokio::test] @@ -1680,6 +1719,48 @@ permission = "write" ); } + #[tokio::test] + async fn subjektiv_rewrite_barrier_cancels_without_pointer_or_legacy_side_effects() { + let calls = Arc::new(AtomicUsize::new(0)); + let client = PendingClient { + calls: Arc::clone(&calls), + }; + let extension_writes = Arc::new(Mutex::new(Vec::new())); + let (event_tx, mut event_rx) = broadcast::channel(16); + let workspace_client = Arc::new(RecordingWorkspaceClient::default()); + let mut task = test_task( + capture(2, 250), + Box::new(client), + Arc::clone(&extension_writes), + event_tx, + workspace_client.clone(), + ); + task.target = ExtractionTarget::Subjektiv; + task.config.profile.consolidation.request_enabled = false; + let registry = start_background_task(task); + tokio::time::timeout(Duration::from_secs(5), async { + while calls.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("subjektiv extraction child should reach its first provider request"); + let rewrite_guard = registry.begin_session_rewrite().await.unwrap(); + drop(rewrite_guard); + registry.shutdown().await.unwrap(); + + assert!(extension_writes.lock().unwrap().is_empty()); + let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| { + matches!(event, Event::MemoryWorker(event) if event.status == "cancelled") + })); + let requests = workspace_client.requests.lock().unwrap(); + assert!( + requests.is_empty(), + "cancelled subjektiv requests: {requests:?}" + ); + } + #[tokio::test] async fn interrupted_committed_run_skips_internal_worker_and_pointer_commit() { let client = ScriptClient::new(Vec::new()); @@ -1972,9 +2053,7 @@ permission = "write" assert!(controller_source.contains("MemoryFeatureInstallPlan::prepare")); assert!(controller_source.contains("MemoryLifecycleFeature::from_resolved_config")); let worker_production = worker_source.split("#[cfg(test)]").next().unwrap(); - let controller_production = controller_source.split("#[cfg(test)]").next().unwrap(); assert!(!worker_production.contains(".feature.memory")); - assert!(!controller_production.contains(".feature.memory")); let lifecycle_source = include_str!("memory_lifecycle.rs"); assert!(lifecycle_source.contains("request_memory_staging_consolidation")); let internal_worker_source = include_str!("../../internal_worker.rs"); From fede0fb51b2c6d2d2d78b6896540bfd26385ca24 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 06:18:02 +0900 Subject: [PATCH 03/31] chore: refresh subjektiv API artifacts --- openapi/server-api.json | 527 +++++++++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 6 files changed, 531 insertions(+), 6 deletions(-) diff --git a/openapi/server-api.json b/openapi/server-api.json index d7745ca2b..b0289a493 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -8694,6 +8694,83 @@ ], "type": "object" }, + "SubjektivSubjectCreateRequest": { + "additionalProperties": false, + "properties": { + "role": { + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "SubjektivSubjectResponse": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "current_worker": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerLaunchWorkerSummary" + }, + { + "type": "null" + } + ], + "description": "Current keyed-singleton owner, when one exists. This is a live Yoi\nprojection, not state duplicated in the subjektiv store." + }, + "id": { + "type": "string" + }, + "role": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/SubjektivSubjectState" + }, + "store_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "role", + "state", + "store_revision", + "created_at", + "updated_at" + ], + "type": "object" + }, + "SubjektivSubjectState": { + "enum": [ + "active", + "retired" + ], + "type": "string" + }, + "SubjektivSubjectWorkerStartRequest": { + "additionalProperties": false, + "properties": { + "worker": { + "$ref": "#/components/schemas/CreateWorkspaceWorkerRequest" + } + }, + "required": [ + "worker" + ], + "type": "object" + }, "SubscriptionWorkerAvailability": { "description": "Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.", "enum": [ @@ -14004,7 +14081,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a" + "x-yoi-source-digest": "sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564" }, "openapi": "3.1.0", "paths": { @@ -27945,6 +28022,454 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects": { + "post": { + "operationId": "subjektiv_subject_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + } + } + }, + "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}": { + "get": { + "operationId": "subjektiv_subject_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/SubjektivSubjectResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": { + "post": { + "operationId": "subjektiv_subject_retire", + "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/SubjektivSubjectResponse" + } + } + }, + "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}/worker": { + "post": { + "operationId": "subjektiv_subject_worker_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/SubjektivSubjectWorkerStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserCreateWorkerResponse" + } + } + }, + "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" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets": { "get": { "operationId": "ticket_list", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 4a43a0719..402ca4ec2 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ed25bd296..b7729a45a 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 090217650..44018280e 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 014210126..0e0269569 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 7085c833c..03244afc9 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From 1f26a7f2bb45237eddbb28d2fe87bf6eb51bc78b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 06:33:30 +0900 Subject: [PATCH 04/31] fix: fence subjektiv completion side effects --- .../src/feature/builtin/memory_lifecycle.rs | 124 +++++++++++++++++- crates/workspace-server/src/lib.rs | 2 + crates/workspace-server/src/server.rs | 20 ++- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 39994a464..7fb6863d9 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -503,9 +503,10 @@ impl MemoryLifecycleTask { }; let manifest = self.extraction_manifest(); + let cancellation_observer = cancellation.clone(); let cancel_observer = move |sender: tokio::sync::mpsc::Sender<()>| { tokio::spawn(async move { - cancellation.cancelled().await; + cancellation_observer.cancelled().await; let _ = sender.send(()).await; }); }; @@ -610,6 +611,21 @@ impl MemoryLifecycleTask { ExtractionDisposition::Completed => {} } + if cancellation.is_cancelled() { + audit + .emit( + self.workspace_client.as_ref(), + self.event_tx.as_ref(), + memory::audit::WorkerLifecycleStatus::Cancelled, + "memory-extract cancelled before completion side effects", + usage_audit, + extract_audit, + None, + ) + .await; + return Ok(()); + } + if self.target == ExtractionTarget::Subjektiv && let Err(reason) = self.record_subjektiv_session(&capture.session_id) { @@ -634,6 +650,20 @@ impl MemoryLifecycleTask { staging_id: pointer_staging_id, }; let payload = serde_json::to_value(&next_pointer).map_err(hook_internal)?; + if cancellation.is_cancelled() { + audit + .emit( + self.workspace_client.as_ref(), + self.event_tx.as_ref(), + memory::audit::WorkerLifecycleStatus::Cancelled, + "memory-extract cancelled before pointer commit", + usage_audit, + extract_audit, + None, + ) + .await; + return Ok(()); + } if !self .extensions .append_if_current(&capture.location(), self.pointer_domain(), payload) @@ -1010,7 +1040,7 @@ fn model_audit_from_manifest(model: &manifest::ModelManifest) -> memory::audit:: #[cfg(test)] mod tests { use std::pin::Pin; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; @@ -1110,6 +1140,52 @@ mod tests { } } + #[derive(Debug, Default)] + struct BlockingSessionWorkspaceClient { + requests: Mutex>, + session_started: AtomicBool, + release_session: AtomicBool, + } + + impl WorkspaceClient for BlockingSessionWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-1") + } + + fn kind(&self) -> &str { + "blocking-subjektiv-session-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + let is_subjektiv_session = request.path.ends_with("/subjektiv/sessions"); + self.requests.lock().unwrap().push(request); + if !is_subjektiv_session { + return Err(crate::worker::WorkspaceClientError::Unavailable( + "blocking client only accepts subject-session recording".to_string(), + )); + } + self.session_started.store(true, Ordering::Release); + while !self.release_session.load(Ordering::Acquire) { + std::thread::yield_now(); + } + Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string(&server_api::SubjektivRecordSessionResponse { + subject_id: "subject-1".to_string(), + session_id: "session-1".to_string(), + }) + .unwrap(), + }) + } + } + #[derive(Clone)] struct PendingClient { calls: Arc, @@ -1599,6 +1675,50 @@ permission = "write" ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancellation_during_subject_session_recording_never_commits_pointer() { + let client = ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]); + let extension_writes = Arc::new(Mutex::new(Vec::new())); + let (event_tx, mut event_rx) = broadcast::channel(32); + let workspace_client = Arc::new(BlockingSessionWorkspaceClient::default()); + let mut task = test_task( + capture(2, 250), + Box::new(client), + Arc::clone(&extension_writes), + event_tx, + workspace_client.clone(), + ); + task.target = ExtractionTarget::Subjektiv; + task.config.profile.consolidation.request_enabled = false; + let registry = start_background_task(task); + tokio::time::timeout(Duration::from_secs(5), async { + while !workspace_client.session_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("subject-session recording should begin"); + + let release_client = workspace_client.clone(); + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + release_client + .release_session + .store(true, Ordering::Release); + }); + let rewrite_guard = registry.begin_session_rewrite().await.unwrap(); + release.await.unwrap(); + drop(rewrite_guard); + registry.shutdown().await.unwrap(); + + assert!(extension_writes.lock().unwrap().is_empty()); + assert_eq!(workspace_client.requests.lock().unwrap().len(), 1); + let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| { + matches!(event, Event::MemoryWorker(event) if event.status == "cancelled") + })); + } + #[tokio::test] async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() { let source = capture(2, 250); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 2210f0866..c23670ced 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -112,6 +112,8 @@ pub enum Error { }, #[error("unknown local repository `{0}`")] UnknownRepository(String), + #[error("subjektiv subject `{0}` was not found in this Workspace")] + SubjektivSubjectNotFound(String), #[error( "Merge Request reopen requires an authenticated Browser session and explicit confirmation" )] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 11b0a321e..34e251b37 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -16619,9 +16619,7 @@ async fn scoped_get_subjektiv_subject( let subject = open_subjektiv_store(&api)? .subject(&path.subject_id) .map_err(|error| Error::Store(error.to_string()))? - .ok_or_else(|| { - Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id)) - })?; + .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; Ok(Json(subjektiv_subject_response(&api, subject))) } @@ -16648,9 +16646,7 @@ async fn scoped_start_subjektiv_subject_worker( let subject = open_subjektiv_store(&api)? .subject(&path.subject_id) .map_err(|error| Error::Store(error.to_string()))? - .ok_or_else(|| { - Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id)) - })?; + .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; if subject.state != crate::subjektiv::SubjectState::Active { return Err(Error::InvalidInput(format!( "subjektiv subject `{}` is retired", @@ -28062,6 +28058,7 @@ fn api_error_status(error: &Error) -> StatusCode { | Error::UnknownRuntime(_) | Error::UnknownWorker { .. } | Error::UnknownRepository(_) + | Error::SubjektivSubjectNotFound(_) | Error::RuntimeBindingNotFound { .. } | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { @@ -34049,6 +34046,17 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; + let missing = scoped_get_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: "missing-subject".to_string(), + }), + ) + .await + .unwrap_err() + .into_response(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); let subject = open_subjektiv_store(&api) .unwrap() .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) From 6d7eac940c704aa7add2d9b15815da177c2e8cde Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 07:18:58 +0900 Subject: [PATCH 05/31] fix(web): restore in-flight blocks through live projection --- .../console/ConsoleLineItem.browser.spec.ts | 113 ++++++ .../src/lib/workspace/console/history.test.ts | 30 ++ .../src/lib/workspace/console/history.ts | 3 +- .../src/lib/workspace/console/model.test.ts | 365 +++++++++++++++++- .../src/lib/workspace/console/model.ts | 147 ++++--- .../workspace/console/turn-navigation.test.ts | 6 +- .../lib/workspace/console/turn-navigation.ts | 6 +- 7 files changed, 598 insertions(+), 72 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts new file mode 100644 index 000000000..d0fabcfd9 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import type { Event } from "$lib/generated/protocol"; +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import ConsoleLineItem from "./ConsoleLineItem.svelte"; +import { createConsoleProjector } from "./model.ts"; + +afterEach(cleanup); + +function reconnectSnapshot(text: string): Event { + return { + event: "snapshot", + data: { + session: { + entries: [], + pending_submissions: { + revision: 0, + head_id: null, + notification_count: 0, + submissions: [], + }, + }, + greeting: { + worker_name: "Worker", + cwd: "/repo", + provider: "provider", + model: "model", + scope_summary: "bounded", + tools: [], + context_window: 100, + context_tokens: 20, + }, + state: { + last_command_id: 0, + state: { kind: "busy", state: { kind: "run", state: "running" } }, + }, + in_flight: { + blocks: [{ kind: "text", text, finished: false }], + }, + }, + }; +} + +test("reconnected assistant output keeps the normal streaming Markdown path", async () => { + const projector = createConsoleProjector(); + let projection = projector.append([{ + eventId: "snapshot", + event: reconnectSnapshot("**hel"), + }]); + const { container, rerender } = render(ConsoleLineItem, { + item: projection.lines[0], + }); + + expect(container.querySelector("li")?.classList.contains("assistant")).toBe( + true, + ); + expect(container.querySelector(".message-heading")).toBeNull(); + expect(container.querySelector(".console-plain-text")).toBeNull(); + expect(container.textContent).not.toContain("in-flight"); + + projection = projector.append([{ + eventId: "inline-suffix", + event: { + event: "text_delta", + data: { text: "lo** and [safe](https://exam" }, + }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe("hello"); + expect(container.querySelector('a[href="https://example.com"]')).toBeNull(); + expect(container.textContent).toContain("[safe](https://exam"); + }); + + projection = projector.append([{ + eventId: "fence-prefix", + event: { + event: "text_delta", + data: { text: "ple.com)\n\n```ts\nconst answer = 42;" }, + }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); + expect(container.querySelector("pre code")?.textContent).toContain( + "const answer = 42;", + ); + }); + + projection = projector.append([{ + eventId: "fence-suffix", + event: { event: "text_delta", data: { text: "\n```" } }, + }]); + await rerender({ item: projection.lines[0] }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe("hello"); + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); + expect(container.querySelector("pre code")?.textContent).toContain( + "const answer = 42;", + ); + }); + + expect(projection.lines).toHaveLength(1); + expect(projection.lines[0]).toMatchObject({ + kind: "assistant", + streaming: true, + body: + "**hello** and [safe](https://example.com)\n\n```ts\nconst answer = 42;\n```", + }); +}); diff --git a/web/workspace/src/lib/workspace/console/history.test.ts b/web/workspace/src/lib/workspace/console/history.test.ts index da5343067..1cc260d99 100644 --- a/web/workspace/src/lib/workspace/console/history.test.ts +++ b/web/workspace/src/lib/workspace/console/history.test.ts @@ -346,6 +346,36 @@ Deno.test("line previews include a current turn outside the retained five-turn p ); }); +Deno.test("line previews track a snapshot-restored assistant as its body streams", () => { + const lines = [{ + id: "user", + entryId: "user-entry", + kind: "user" as const, + title: "User", + body: "question", + source: "event" as const, + }, { + id: "restored-assistant", + kind: "assistant" as const, + title: "assistant streaming", + body: "**hel", + source: "event" as const, + streaming: true, + }]; + + assert( + conversationTurnPreviewsFromLines(lines)[0].assistant.join("|") === + "**hel", + "restored assistant prefix must use the ordinary assistant preview path", + ); + const continued = [lines[0], { ...lines[1], body: "**hello**" }]; + assert( + conversationTurnPreviewsFromLines(continued)[0].assistant.join("|") === + "**hello**", + "live suffixes must update the same turn preview", + ); +}); + Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => { const preview = conversationTurnPreviews([turn(4)])[0]; assert(preview.user === "question 4", "user preview must use its first line"); diff --git a/web/workspace/src/lib/workspace/console/history.ts b/web/workspace/src/lib/workspace/console/history.ts index baa26442b..340a59729 100644 --- a/web/workspace/src/lib/workspace/console/history.ts +++ b/web/workspace/src/lib/workspace/console/history.ts @@ -203,8 +203,7 @@ export function conversationTurnPreviewsFromLines( assistants: [], }; } else if ( - (line.kind === "assistant" || - (line.kind === "in_flight" && line.title === "in-flight assistant text")) && + line.kind === "assistant" && current && line.body.trim().length > 0 ) { current.assistants.push(line.body); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index adaa5c669..36b22d895 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -1,5 +1,6 @@ import type { Event, + InFlightBlock, SessionSnapshotEntry, WorkerStateSnapshot, WorkerStatus, @@ -176,6 +177,39 @@ function snapshotEvent(cwd: string, entries: unknown[] = []): Event { }; } +type SnapshotEvent = Extract; + +function snapshotWithInFlight( + blocks: InFlightBlock[], + entries: unknown[] = [], +): SnapshotEvent { + const snapshot = snapshotEvent("/repo", entries) as SnapshotEvent; + snapshot.data.in_flight = { blocks }; + return snapshot; +} + +function semanticOutput(lines: readonly ConsoleLine[]) { + return lines.map((line) => ({ + kind: line.kind, + title: line.title, + body: line.body, + streaming: line.streaming, + entryId: line.entryId, + toolCall: line.toolCall + ? { + id: line.toolCall.id, + name: line.toolCall.name, + argsStream: line.toolCall.argsStream, + arguments: line.toolCall.arguments, + state: line.toolCall.state, + summary: line.toolCall.summary, + output: line.toolCall.output, + isError: line.toolCall.isError, + } + : undefined, + })); +} + Deno.test("large paste segments project compact artifact metadata", () => { const body = "secret pasted body"; const text = segmentsToText([{ @@ -2334,11 +2368,273 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () => "user:new user:false", "assistant:assistant reply:false", "tool:Read(1 file)\n /tmp/a.md:false", - "in_flight:partial:true", + "assistant:partial:true", ], ); }); +Deno.test("snapshot text blocks match live prefixes, suffixes, and ordered commits", () => { + const committedPrefix = [{ + kind: "user_input", + segments: [{ kind: "text", content: "question" }], + }]; + const livePrefix: ConsoleEventInput[] = [ + { eventId: "live-base", event: snapshotEvent("/repo", committedPrefix) }, + { + eventId: "live-first-delta", + event: { event: "text_delta", data: { text: "first draft" } }, + }, + { + eventId: "live-first-done", + event: { event: "text_done", data: { text: "first draft" } }, + }, + { + eventId: "live-second-delta", + event: { event: "text_delta", data: { text: "**hel" } }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored-base", + event: snapshotWithInFlight([ + { kind: "text", text: "first draft", finished: true }, + { kind: "text", text: "**hel", finished: false }, + ], committedPrefix), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const firstCommit: SessionSnapshotEntry = { + entry_id: "assistant-first", + timestamp: 2, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "first canonical" }], + }; + const secondCommit: SessionSnapshotEntry = { + entry_id: "assistant-second", + timestamp: 3, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "**hello**" }], + }; + const suffix: ConsoleEventInput[] = [ + { + eventId: "commit-first", + event: { + event: "session_entry_committed", + data: { entry: firstCommit }, + }, + }, + { + eventId: "second-suffix", + event: { event: "text_delta", data: { text: "lo**" } }, + }, + { + eventId: "second-done", + event: { event: "text_done", data: { text: "**hello**" } }, + }, + { + eventId: "commit-second", + event: { + event: "session_entry_committed", + data: { entry: secondCommit }, + }, + }, + ]; + const liveFinal = projectConsole([...livePrefix, ...suffix]); + const restoredFinal = projectConsole([...restoredPrefix, ...suffix]); + + assertEquals( + semanticOutput(restoredFinal.lines), + semanticOutput(liveFinal.lines), + ); + assertEquals( + restoredFinal.lines.filter((line) => line.kind === "assistant").map( + (line) => [line.body, line.entryId, line.streaming], + ), + [ + ["first canonical", "assistant-first", false], + ["**hello**", "assistant-second", false], + ], + ); +}); + +Deno.test("snapshot thinking blocks preserve boundaries and live continuation", () => { + const livePrefix: ConsoleEventInput[] = [ + { eventId: "base", event: snapshotEvent("/repo") }, + { eventId: "thinking-1-start", event: { event: "thinking_start" } }, + { + eventId: "thinking-1-delta", + event: { event: "thinking_delta", data: { text: "finished thought" } }, + }, + { + eventId: "thinking-1-done", + event: { event: "thinking_done", data: { text: "finished thought" } }, + }, + { eventId: "thinking-2-start", event: { event: "thinking_start" } }, + { + eventId: "thinking-2-delta", + event: { event: "thinking_delta", data: { text: "next tho" } }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored", + event: snapshotWithInFlight([ + { kind: "thinking", text: "finished thought", finished: true }, + { kind: "thinking", text: "next tho", finished: false }, + ]), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const suffix: ConsoleEventInput[] = [ + { + eventId: "thinking-2-suffix", + event: { event: "thinking_delta", data: { text: "ught" } }, + }, + { + eventId: "thinking-2-done", + event: { event: "thinking_done", data: { text: "next thought" } }, + }, + ]; + const restored = projectConsole([...restoredPrefix, ...suffix]); + assertEquals( + semanticOutput(restored.lines), + semanticOutput(projectConsole([...livePrefix, ...suffix]).lines), + ); + assertEquals( + restored.lines.map((line) => [line.kind, line.body, line.streaming]), + [ + ["thinking", "finished thought", false], + ["thinking", "next thought", false], + ], + ); + assertEquals(projectOverviewLines(restored.lines), []); +}); + +Deno.test("snapshot tool calls retain identity and state through live suffixes", () => { + const livePrefix: ConsoleEventInput[] = [ + { eventId: "base", event: snapshotEvent("/repo") }, + { + eventId: "pending-start", + event: { + event: "tool_call_start", + data: { id: "pending", name: "Read" }, + }, + }, + { + eventId: "streaming-start", + event: { + event: "tool_call_start", + data: { id: "streaming", name: "Bash" }, + }, + }, + { + eventId: "streaming-args", + event: { + event: "tool_call_args_delta", + data: { id: "streaming", json: '{"command":"ec' }, + }, + }, + { + eventId: "done-start", + event: { event: "tool_call_start", data: { id: "done", name: "Glob" } }, + }, + { + eventId: "done-args", + event: { + event: "tool_call_args_delta", + data: { id: "done", json: '{"pattern":"*.ts"}' }, + }, + }, + { + eventId: "done-call", + event: { + event: "tool_call_done", + data: { id: "done", name: "Glob", arguments: '{"pattern":"*.ts"}' }, + }, + }, + ]; + const restoredPrefix: ConsoleEventInput[] = [{ + eventId: "restored", + event: snapshotWithInFlight([ + { + kind: "tool_call", + id: "pending", + name: "Read", + args: "", + state: "pending", + }, + { + kind: "tool_call", + id: "streaming", + name: "Bash", + args: '{"command":"ec', + state: "streaming_args", + }, + { + kind: "tool_call", + id: "done", + name: "Glob", + args: '{"pattern":"*.ts"}', + state: "done", + }, + ]), + }]; + + assertEquals( + semanticOutput(projectConsole(restoredPrefix).lines), + semanticOutput(projectConsole(livePrefix).lines), + ); + + const suffix: ConsoleEventInput[] = [ + { + eventId: "pending-done", + event: { + event: "tool_call_done", + data: { id: "pending", name: "Read", arguments: '{"file_path":"a"}' }, + }, + }, + { + eventId: "streaming-suffix", + event: { + event: "tool_call_args_delta", + data: { id: "streaming", json: 'ho hi"}' }, + }, + }, + { + eventId: "streaming-done", + event: { + event: "tool_call_done", + data: { + id: "streaming", + name: "Bash", + arguments: '{"command":"echo hi"}', + }, + }, + }, + ...["pending", "streaming", "done"].map((id): ConsoleEventInput => ({ + eventId: `${id}-result`, + event: { + event: "tool_result", + data: { id, summary: `${id} result`, output: id, is_error: false }, + }, + })), + ]; + assertEquals( + semanticOutput(projectConsole([...restoredPrefix, ...suffix]).lines), + semanticOutput(projectConsole([...livePrefix, ...suffix]).lines), + ); +}); + Deno.test("projectConsole restores system items from snapshot entries", () => { const projection = projectConsole([{ eventId: "system-snapshot", @@ -2666,6 +2962,65 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null); }); +Deno.test("Internal Worker snapshot output continues without entering the parent view", () => { + const worker = { + session_id: "child-reconnect", + name: "research", + parent_session_id: "parent-session", + kind: "sub_worker" as const, + }; + const snapshot = snapshotEvent("/repo") as SnapshotEvent; + snapshot.data.in_flight = { + blocks: [{ kind: "text", text: "parent", finished: false }], + }; + snapshot.data.internal_workers = [{ + worker, + revision: 4, + session: canonicalSession([]), + status: "running", + in_flight: { + blocks: [{ kind: "text", text: "**chi", finished: false }], + }, + internal_workers: [], + }]; + + const projector = createConsoleProjector(); + let projection = projector.append([{ eventId: "snapshot", event: snapshot }]); + assertEquals(projection.lines.map((line) => line.body), ["parent"]); + assertEquals( + projection.internalWorkers[0].console.lines.map((line) => line.body), + ["**chi"], + ); + + projection = projector.append([ + { + eventId: "parent-suffix", + event: { event: "text_delta", data: { text: " only" } }, + }, + { + eventId: "child-suffix", + event: { + event: "internal_worker", + data: { + worker, + revision: 5, + event: { event: "text_delta", data: { text: "ld**" } }, + }, + }, + }, + ]); + + assertEquals(projection.lines.map((line) => line.body), ["parent only"]); + assertEquals( + projection.internalWorkers[0].console.lines.map((line) => [ + line.kind, + line.body, + line.streaming, + ]), + [["assistant", "**child**", true]], + ); +}); + Deno.test("console Worker views expose only direct Internal Workers", () => { const projector = createConsoleProjector(); projector.append([{ @@ -3032,10 +3387,7 @@ Deno.test("overview hides thinking and aggregates uninterrupted tool activity", Deno.test("overview keeps tool failure counts without marking the activity as an error", () => { const overview = projectOverviewLines([ - { - ...consoleLine("thinking-in-flight", "in_flight"), - title: "in-flight thinking", - }, + consoleLine("thinking-active", "thinking"), { ...consoleLine("failed-read", "tool"), error: true, @@ -3398,7 +3750,8 @@ Deno.test("committed history inserts current live rows after their last stable a historyUser.entryId = "user-1"; const currentUser = consoleLine("current-user", "user"); currentUser.entryId = "user-1"; - const live = consoleLine("live-assistant", "in_flight"); + const live = consoleLine("live-assistant", "assistant"); + live.streaming = true; assertEquals( mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) => diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index cdfca96d2..af9a20446 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -40,7 +40,6 @@ export type ConsoleLineKind = | "status" | "error" | "usage" - | "in_flight" | "system"; type ToolCallState = @@ -361,8 +360,7 @@ function projectVisibleConsole( } function isOverviewThinkingLine(line: ConsoleLine): boolean { - return line.kind === "thinking" || - (line.kind === "in_flight" && line.title === "in-flight thinking"); + return line.kind === "thinking"; } function representedToolCallCount(line: ConsoleLine): number { @@ -559,12 +557,58 @@ function appendSnapshotInFlightLines( eventId: string, cwd: string | null, ): void { - const lineIds = new Set(projection.lines.map((line) => line.id)); blocks.forEach((block, index) => { - const pending = inFlightLine(`${eventId}:${index}`, block, cwd); - if (lineIds.has(pending.id)) return; - projection.lines.push(pending); - lineIds.add(pending.id); + const blockEventId = `${eventId}:${index}`; + switch (block.kind) { + case "text": + appendStreaming( + projection, + blockEventId, + "assistant", + "assistant streaming", + block.text, + ); + if (block.finished) { + finalizeStreaming( + projection, + "assistant", + blockEventId, + "assistant", + block.text, + ); + } + break; + case "thinking": + startStreaming(projection, blockEventId, "thinking", "Thinking..."); + if (block.text) { + appendStreaming( + projection, + blockEventId, + "thinking", + "Thinking...", + block.text, + ); + } + if (block.finished) { + finalizeStreaming( + projection, + "thinking", + blockEventId, + "Thought", + block.text, + ); + } + break; + case "tool_call": + upsertToolCall(projection, blockEventId, block.id, { + name: block.name, + argsStream: block.args, + arguments: block.state === "done" ? block.args : undefined, + state: inFlightToolState(block.state), + cwd, + }); + break; + } }); } @@ -924,10 +968,23 @@ export function applyProtocolEvent( entryId: event.data.entry_id ?? undefined, }); break; - case "session_entry_committed": - reconcileCommittedSessionEntry(next, event.data.entry); + case "session_entry_committed": { + const replacementIndex = reconcileCommittedSessionEntry( + next, + event.data.entry, + ); applySessionEntry(next, envelope.eventId, event.data.entry); + if (replacementIndex !== undefined) { + const committedIndex = next.lines.findIndex((line) => + line.entryId === event.data.entry.entry_id + ); + if (committedIndex >= 0) { + const [committed] = next.lines.splice(committedIndex, 1); + next.lines.splice(replacementIndex, 0, committed); + } + } break; + } case "system_item": next.lines.push(systemItemLine(envelope.eventId, event.data.item)); applyTaskSystemItem(next, event.data.item); @@ -951,9 +1008,7 @@ export function applyProtocolEvent( ); break; case "thinking_start": - next.lines.push( - line(envelope.eventId, "thinking", "Thinking...", "", undefined, true), - ); + startStreaming(next, envelope.eventId, "thinking", "Thinking..."); break; case "thinking_delta": appendStreaming( @@ -1296,7 +1351,8 @@ function appendDurableRunFailure( ) return; projection.lines = projection.lines.filter((line) => { if (isDurableRunFailure(line)) return true; - return !(line.kind === "error" && runFailureMessagesMatch(line.body, message)); + return !(line.kind === "error" && + runFailureMessagesMatch(line.body, message)); }); projection.lines.push({ ...line( @@ -1344,6 +1400,15 @@ function findLastLineIndex( return -1; } +function startStreaming( + projection: ConsoleProjection, + eventId: string, + kind: "assistant" | "thinking", + title: string, +): void { + projection.lines.push(line(eventId, kind, title, "", undefined, true)); +} + function appendStreaming( projection: ConsoleProjection, eventId: string, @@ -2131,16 +2196,22 @@ function snapshotProjectionFromSession( function reconcileCommittedSessionEntry( projection: ConsoleProjection, entry: SessionSnapshotEntry, -): void { +): number | undefined { if (entry.kind !== "message" || entry.role !== "assistant") return; - for (let index = projection.lines.length - 1; index >= 0; index -= 1) { + const turnStart = findLastLineIndex( + projection.lines, + (line) => line.kind === "user", + ); + for (let index = turnStart + 1; index < projection.lines.length; index += 1) { const line = projection.lines[index]; - if (line.entryId) continue; - if (line.kind === "assistant" || line.kind === "in_flight") { + if ( + line.kind === "assistant" && + !line.entryId && + line.streaming !== true + ) { projection.lines.splice(index, 1); - return; + return index; } - if (line.kind === "user") return; } } @@ -2358,42 +2429,6 @@ function loggedContentText(parts: unknown[]): string { .join("\n"); } -function inFlightLine( - eventId: string, - block: InFlightBlock, - cwd: string | null, -): ConsoleLine { - switch (block.kind) { - case "text": - return line( - eventId, - "in_flight", - "in-flight assistant text", - block.text, - undefined, - !block.finished, - ); - case "thinking": - return line( - eventId, - "in_flight", - "in-flight thinking", - block.text, - undefined, - !block.finished, - ); - case "tool_call": - return toolLine(eventId, { - id: block.id, - name: block.name, - argsStream: block.args, - arguments: block.state === "done" ? block.args : undefined, - state: inFlightToolState(block.state), - cwd, - }); - } -} - function inFlightToolState( state: InFlightToolCallState | undefined, ): ToolCallState { diff --git a/web/workspace/src/lib/workspace/console/turn-navigation.test.ts b/web/workspace/src/lib/workspace/console/turn-navigation.test.ts index df3aae3a5..3eb496ffa 100644 --- a/web/workspace/src/lib/workspace/console/turn-navigation.test.ts +++ b/web/workspace/src/lib/workspace/console/turn-navigation.test.ts @@ -54,11 +54,11 @@ Deno.test("streaming updates change the preview without creating another turn", equal(answer.body, "Hi"); }); -Deno.test("snapshot in-flight assistant text is previewed but in-flight thinking is not", () => { +Deno.test("snapshot-restored assistant text is previewed but thinking is not", () => { equal(consoleTurns([ line("u", "user", "Question"), - { ...line("reasoning", "in_flight", "Private"), title: "in-flight thinking" }, - { ...line("text", "in_flight", "Answer in progress"), title: "in-flight assistant text" }, + line("reasoning", "thinking", "Private"), + line("text", "assistant", "Answer in progress"), ]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]); }); diff --git a/web/workspace/src/lib/workspace/console/turn-navigation.ts b/web/workspace/src/lib/workspace/console/turn-navigation.ts index 038cb7a5f..09a52b13c 100644 --- a/web/workspace/src/lib/workspace/console/turn-navigation.ts +++ b/web/workspace/src/lib/workspace/console/turn-navigation.ts @@ -23,11 +23,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] { if (line.kind === "user") { current = { id: line.id, user: previewText(line.body), assistant: "" }; turns.push(current); - } else if ( - current && - (line.kind === "assistant" || - (line.kind === "in_flight" && line.title === "in-flight assistant text")) - ) { + } else if (current && line.kind === "assistant") { const assistant = previewText(line.body); if (assistant) current.assistant = assistant; } From b39139224613f350a8cc48a87f65afa15c38aa34 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 07:39:04 +0900 Subject: [PATCH 06/31] feat: add subject-scoped memory tools --- crates/server-api/src/lib.rs | 279 +++++ crates/worker-runtime/src/worker_source.rs | 5 + crates/worker/src/controller.rs | 9 + crates/worker/src/feature/builtin.rs | 1 + .../feature/builtin/memory_staging_output.rs | 4 +- .../src/feature/builtin/subjektiv_memory.rs | 962 +++++++++++++++ crates/worker/src/session_capture.rs | 38 + crates/worker/src/worker.rs | 4 + crates/workspace-server/src/server.rs | 1039 ++++++++++++++++- crates/workspace-server/src/subjektiv.rs | 653 ++++++++++- docs/design/subjektiv-store.md | 61 + openapi/server-api.json | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 17 files changed, 3048 insertions(+), 19 deletions(-) create mode 100644 crates/worker/src/feature/builtin/subjektiv_memory.rs diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index cf2fbdb9c..2dadb6ef2 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1176,6 +1176,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivRecordSessionRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/memory", + operation_id = "subjektiv_memory_backend", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_memory_backend( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: SubjektivMemoryBackendRequest, + ) -> Result; #[get( "/api/w/{workspace_id}/skills", @@ -4506,6 +4520,270 @@ pub struct SubjektivRecordSessionResponse { pub session_id: String, } +/// Current lifecycle state of one confirmed subject Memory revision. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryState { + Active, + Resolved, + Retracted, +} + +/// Requested state/change semantics for a staging-only revision proposal. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryRevisionIntent { + Revise, + Resolve, + Retract, + Reopen, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionRef { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, +} + +/// Typed metadata retained with a revision proposal candidate for T-670. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionProposal { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub expected_revision: u64, + pub intent: SubjektivMemoryRevisionIntent, + pub change_reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryProposalTarget { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub expected_revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryQueryRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub states: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryReadRequest { + pub memory_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 1000))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryListRevisionsRequest { + pub memory_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryValidateProposalRequest { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub expected_revision: u64, + pub intent: SubjektivMemoryRevisionIntent, +} + +/// Host-resolved staging input. Subject scope and evidence origin are never +/// accepted from model-visible tool arguments. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryStageExplicitRequest { + pub receipt_id: String, + pub session_id: String, + pub kind: memory::extract::CandidateKind, + pub claim: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + pub source_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proposal: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "operation", content = "input", rename_all = "snake_case")] +pub enum SubjektivMemoryBackendOperation { + Query(SubjektivMemoryQueryRequest), + Read(SubjektivMemoryReadRequest), + ListRevisions(SubjektivMemoryListRevisionsRequest), + ValidateProposal(SubjektivMemoryValidateProposalRequest), + StageExplicit(SubjektivMemoryStageExplicitRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryBackendRequest { + pub operation: SubjektivMemoryBackendOperation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryQueryItem { + pub id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub kind: memory::extract::CandidateKind, + pub state: SubjektivMemoryState, + pub claim: String, + pub excerpt: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryQueryResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryEvidenceCandidate { + pub candidate_id: String, + pub evidence: Vec, + pub source_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryReadResponse { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub current_revision: u64, + pub kind: memory::extract::CandidateKind, + pub state: SubjektivMemoryState, + pub claim: String, + /// A line-bounded slice, never an implicit full-document claim. + pub body_md: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + pub change_reason: String, + pub created_at: String, + pub updated_at: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub body_offset: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub body_next_offset: Option, + pub body_truncated: bool, + /// Candidate ids on this evidence page. The corresponding bounded anchors + /// are returned in `source_candidates` with the same order. + pub source_candidate_ids: Vec, + pub source_candidates: Vec, + /// Derivation references on this evidence page. + pub derived_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_next_cursor: Option, + pub evidence_has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionItem { + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub kind: memory::extract::CandidateKind, + pub state: SubjektivMemoryState, + pub claim: String, + pub change_reason: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryListRevisionsResponse { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub current_revision: u64, + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryProposalValidationResponse { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub current_revision: u64, + pub kind: memory::extract::CandidateKind, + pub state: SubjektivMemoryState, + pub intent: SubjektivMemoryRevisionIntent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryReceiptStatus { + Staged, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryStageExplicitResponse { + pub candidate_id: String, + pub receipt_id: String, + pub status: SubjektivMemoryReceiptStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "result", content = "data", rename_all = "snake_case")] +pub enum SubjektivMemoryBackendResponse { + Query(SubjektivMemoryQueryResponse), + Read(SubjektivMemoryReadResponse), + ListRevisions(SubjektivMemoryListRevisionsResponse), + ProposalValidated(SubjektivMemoryProposalValidationResponse), + Staged(SubjektivMemoryStageExplicitResponse), +} + pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; @@ -11673,6 +11951,7 @@ mod openapi_artifact_tests { "worker_observation_sessions", "subjektiv_stage_candidate", "subjektiv_record_session", + "subjektiv_memory_backend", "workspace_worker_discovery", "workspace_worker_remove", ]; diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 3c6dbfccf..db1ae1c11 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -576,6 +576,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { client.subjektiv_record_session(workspace_id, request).await, ) } + WorkspaceServerOperation::SubjektivMemory(request) => { + generated_workspace_response( + client.subjektiv_memory_backend(workspace_id, request).await, + ) + } } }) }) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 0da10dcf0..0fee205bb 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1287,6 +1287,15 @@ where if let Some(plan) = memory_install_plan { feature_registry.add_module(plan.module); } + if let Some(subjektiv_memory) = + crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? + { + feature_registry.add_module(subjektiv_memory); + } if let Some(memory_config) = memory_lifecycle_config && let Some(memory_lifecycle) = crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 417c9ca80..2469afd82 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -16,6 +16,7 @@ pub mod objective; pub mod orchestration; mod resource_projection; pub mod session_explore; +pub(crate) mod subjektiv_memory; pub mod task; pub mod ticket; pub mod worker_observation; diff --git a/crates/worker/src/feature/builtin/memory_staging_output.rs b/crates/worker/src/feature/builtin/memory_staging_output.rs index 2b9b7a09b..e51939a3a 100644 --- a/crates/worker/src/feature/builtin/memory_staging_output.rs +++ b/crates/worker/src/feature/builtin/memory_staging_output.rs @@ -410,7 +410,7 @@ fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin } } -fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { +pub(crate) fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { StagingEvidence { id: entry.entry_ref.to_string(), kind: evidence_kind(entry), @@ -421,7 +421,7 @@ fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { } } -fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef { +pub(crate) fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef { SourceEvidenceRef { segment_id: Some(entry.segment_id.clone()), entry_range: Some(entry.entry_range), diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs new file mode 100644 index 000000000..20c469752 --- /dev/null +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -0,0 +1,962 @@ +//! Host-scoped model tools for revisioned subjektiv Memory. +//! +//! Confirmed Memory is read-only here. Explicit remembering and revision requests +//! become immutable staging candidates; T-670 remains the only authority that may +//! adopt a candidate as a confirmed revision. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use async_trait::async_trait; +use memory::extract::CandidateKind; +use schemars::JsonSchema; +use serde::Deserialize; +use server_api::{ + SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse, + SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest, + SubjektivMemoryReceiptStatus, SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal, + SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse, + SubjektivMemoryValidateProposalRequest, +}; + +use crate::feature::builtin::memory_staging_output::{source_evidence_ref, staging_evidence}; +use crate::feature::session::CommittedSessionCaptureHandle; +use crate::feature::{ + FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, + HookDeclaration, ToolContribution, ToolDeclaration, +}; +use crate::hook::{ + Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, RunCommitted, + RunCommittedContext, +}; +use crate::session_capture::{SessionCapture, SessionEntryEvidence}; +use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; + +const QUERY_TOOL: &str = "SubjektivMemoryQuery"; +const READ_TOOL: &str = "SubjektivMemoryRead"; +const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions"; +const REMEMBER_TOOL: &str = "SubjektivMemoryRemember"; +const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision"; +const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit"; + +const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot."; +const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line-bounded Markdown and paged provenance anchors. Continue partial reads using the returned exact revision."; +const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination."; +const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit."; +const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision."; + +#[derive(Clone)] +pub(crate) struct SubjektivMemoryFeature { + state: SubjektivMemoryState, +} + +#[derive(Clone)] +struct SubjektivMemoryState { + client: Arc, + capture: CommittedSessionCaptureHandle, + pending: Arc>>, +} + +#[derive(Clone)] +struct PendingExplicit { + receipt_id: String, + call_id: String, + kind: CandidateKind, + claim: String, + why_useful: String, + staleness: Option, + proposal: Option, +} + +impl SubjektivMemoryFeature { + pub(crate) fn from_resolved_config( + config: &manifest::ResolvedSubjektivFeatureConfig, + capture: CommittedSessionCaptureHandle, + client: Arc, + ) -> std::io::Result> { + if !config.profile.enabled { + return Ok(None); + } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !client.is_available() || client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv Memory tools require Backend Workspace API authority", + )); + } + Ok(Some(Self { + state: SubjektivMemoryState { + client, + capture, + pending: Arc::new(Mutex::new(HashMap::new())), + }, + })) + } +} + +impl FeatureModule for SubjektivMemoryFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("subjektiv-memory-tools", "subjektiv Memory Tools") + .with_description("Subject-scoped recall and staging-only explicit Memory tools.") + .with_tool(ToolDeclaration::new(QUERY_TOOL, QUERY_DESCRIPTION)) + .with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION)) + .with_tool(ToolDeclaration::new( + LIST_REVISIONS_TOOL, + LIST_REVISIONS_DESCRIPTION, + )) + .with_tool(ToolDeclaration::new(REMEMBER_TOOL, REMEMBER_DESCRIPTION)) + .with_tool(ToolDeclaration::new( + PROPOSE_REVISION_TOOL, + PROPOSE_DESCRIPTION, + )) + .with_hook(HookDeclaration::new( + COMMIT_HOOK, + FeatureHookPoint::RunCommitted, + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for (name, description, operation) in [ + (QUERY_TOOL, QUERY_DESCRIPTION, ReadOperation::Query), + (READ_TOOL, READ_DESCRIPTION, ReadOperation::Read), + ( + LIST_REVISIONS_TOOL, + LIST_REVISIONS_DESCRIPTION, + ReadOperation::ListRevisions, + ), + ] { + context.tools().register(ToolContribution::new( + name, + read_tool_definition(name, description, self.state.clone(), operation), + ))?; + } + context.tools().register(ToolContribution::new( + REMEMBER_TOOL, + explicit_tool_definition( + REMEMBER_TOOL, + REMEMBER_DESCRIPTION, + self.state.clone(), + ExplicitOperation::Remember, + ), + ))?; + context.tools().register(ToolContribution::new( + PROPOSE_REVISION_TOOL, + explicit_tool_definition( + PROPOSE_REVISION_TOOL, + PROPOSE_DESCRIPTION, + self.state.clone(), + ExplicitOperation::Propose, + ), + ))?; + context.hooks().add_run_committed( + COMMIT_HOOK, + HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000), + PendingCommitHook { + state: self.state.clone(), + }, + ) + } +} + +#[derive(Clone, Copy)] +enum ReadOperation { + Query, + Read, + ListRevisions, +} + +fn read_tool_definition( + name: &'static str, + description: &'static str, + state: SubjektivMemoryState, + operation: ReadOperation, +) -> ToolDefinition { + Arc::new(move || { + let schema = match operation { + ReadOperation::Query => schema_for::(), + ReadOperation::Read => schema_for::(), + ReadOperation::ListRevisions => schema_for::(), + }; + let tool: Arc = Arc::new(SubjektivReadTool { + state: state.clone(), + operation, + }); + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema), + tool, + ) + }) +} + +struct SubjektivReadTool { + state: SubjektivMemoryState, + operation: ReadOperation, +} + +#[async_trait] +impl Tool for SubjektivReadTool { + async fn execute( + &self, + input_json: &str, + _context: ToolExecutionContext, + ) -> Result { + let operation = match self.operation { + ReadOperation::Query => { + SubjektivMemoryBackendOperation::Query(parse(input_json, QUERY_TOOL)?) + } + ReadOperation::Read => { + SubjektivMemoryBackendOperation::Read(parse(input_json, READ_TOOL)?) + } + ReadOperation::ListRevisions => SubjektivMemoryBackendOperation::ListRevisions(parse( + input_json, + LIST_REVISIONS_TOOL, + )?), + }; + let response = self.state.execute(operation)?; + let (summary, value) = match response { + SubjektivMemoryBackendResponse::Query(value) => ( + format!("Found {} Memory item(s).", value.items.len()), + serde_json::to_value(value), + ), + SubjektivMemoryBackendResponse::Read(value) => ( + format!( + "Read Memory {} revision {}.", + value.memory_id, value.revision + ), + serde_json::to_value(value), + ), + SubjektivMemoryBackendResponse::ListRevisions(value) => ( + format!( + "Listed {} revision(s) for Memory {}.", + value.items.len(), + value.memory_id + ), + serde_json::to_value(value), + ), + other => { + return Err(ToolError::ExecutionFailed(format!( + "unexpected subjektiv backend response: {other:?}" + ))); + } + }; + let value = value.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + Ok(json_output(summary, &value)?) + } +} + +#[derive(Clone, Copy)] +enum ExplicitOperation { + Remember, + Propose, +} + +fn explicit_tool_definition( + name: &'static str, + description: &'static str, + state: SubjektivMemoryState, + operation: ExplicitOperation, +) -> ToolDefinition { + Arc::new(move || { + let schema = match operation { + ExplicitOperation::Remember => schema_for::(), + ExplicitOperation::Propose => schema_for::(), + }; + let tool: Arc = Arc::new(SubjektivExplicitTool { + state: state.clone(), + operation, + }); + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema), + tool, + ) + }) +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct RememberParams { + kind: CandidateKind, + claim: String, + why_useful: String, + #[serde(default)] + staleness: Option, + #[serde(default)] + entry_refs: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ProposeRevisionParams { + memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + expected_revision: u64, + intent: SubjektivMemoryRevisionIntent, + claim: String, + why_useful: String, + change_reason: String, + #[serde(default)] + staleness: Option, + #[serde(default)] + entry_refs: Vec, +} + +struct SubjektivExplicitTool { + state: SubjektivMemoryState, + operation: ExplicitOperation, +} + +#[async_trait] +impl Tool for SubjektivExplicitTool { + async fn execute( + &self, + input_json: &str, + context: ToolExecutionContext, + ) -> Result { + let call_id = required(&context.call_id, "tool call_id")?.to_string(); + let capture = self + .state + .capture + .capture() + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + let receipt_id = receipt_id(&capture.session_id, &call_id)?; + let (kind, claim, why_useful, staleness, entry_refs, proposal) = match self.operation { + ExplicitOperation::Remember => { + let params: RememberParams = parse(input_json, REMEMBER_TOOL)?; + ( + params.kind, + params.claim, + params.why_useful, + params.staleness, + params.entry_refs, + None, + ) + } + ExplicitOperation::Propose => { + let params: ProposeRevisionParams = parse(input_json, PROPOSE_REVISION_TOOL)?; + if params.expected_revision == 0 { + return Err(ToolError::InvalidArgument( + "expected_revision must be a positive integer".into(), + )); + } + let validation = + self.state + .execute(SubjektivMemoryBackendOperation::ValidateProposal( + SubjektivMemoryValidateProposalRequest { + memory_id: params.memory_id.clone(), + expected_revision: params.expected_revision, + intent: params.intent, + }, + ))?; + let validated = match validation { + SubjektivMemoryBackendResponse::ProposalValidated(value) => value, + other => { + return Err(ToolError::ExecutionFailed(format!( + "unexpected proposal validation response: {other:?}" + ))); + } + }; + let proposal = SubjektivMemoryRevisionProposal { + memory_id: params.memory_id, + expected_revision: params.expected_revision, + intent: params.intent, + change_reason: params.change_reason, + }; + ( + validated.kind, + params.claim, + params.why_useful, + params.staleness, + params.entry_refs, + Some(proposal), + ) + } + }; + validate_candidate_text(&claim, &why_useful)?; + if proposal + .as_ref() + .is_some_and(|proposal| proposal.change_reason.trim().is_empty()) + { + return Err(ToolError::InvalidArgument( + "change_reason must not be empty".into(), + )); + } + + if entry_refs.is_empty() { + if kind == CandidateKind::Preference { + return Err(ToolError::InvalidArgument( + "preference candidates require committed HumanInput entry_refs; the model-authored tool call is not preference authority" + .into(), + )); + } + let pending = PendingExplicit { + receipt_id: receipt_id.clone(), + call_id, + kind, + claim, + why_useful, + staleness, + proposal: proposal.clone(), + }; + let mut queue = self.state.pending.lock().map_err(|_| { + ToolError::ExecutionFailed("subjektiv pending queue poisoned".into()) + })?; + match queue.get(&receipt_id) { + Some(existing) if !pending_equivalent(existing, &pending) => { + return Err(ToolError::InvalidArgument( + "this tool operation was retried with different content".into(), + )); + } + Some(_) => {} + None => { + queue.insert(receipt_id.clone(), pending); + } + } + let value = serde_json::json!({ + "receipt_id": receipt_id, + "status": "pending_commit", + "target": proposal.as_ref().map(|value| serde_json::json!({ + "memory_id": value.memory_id, + "expected_revision": value.expected_revision, + })), + "intent": proposal.as_ref().map(|value| value.intent), + "message": "Candidate is not confirmed Memory. It will be staged only after this tool call is durably committed; an uncommitted/cancelled operation is discarded.", + }); + return json_output( + "Accepted explicit Memory candidate pending Session commit.", + &value, + ); + } + + let entries = resolve_entry_refs(&capture, &entry_refs)?; + ensure_preference_evidence(&kind, &entries)?; + let response = self.state.stage( + &receipt_id, + &capture.session_id, + kind, + claim, + why_useful, + staleness, + entries, + proposal, + )?; + staged_output(response) + } +} + +impl SubjektivMemoryState { + fn execute( + &self, + operation: SubjektivMemoryBackendOperation, + ) -> Result { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivMemory( + SubjektivMemoryBackendRequest { operation }, + )) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + let message = format!( + "subjektiv Memory backend returned HTTP {}: {}", + response.status, response.body + ); + return if matches!(response.status, 400 | 404 | 409 | 422) { + Err(ToolError::InvalidArgument(message)) + } else { + Err(ToolError::ExecutionFailed(message)) + }; + } + serde_json::from_str(&response.body).map_err(|error| { + ToolError::ExecutionFailed(format!("decode subjektiv response: {error}")) + }) + } + + #[allow(clippy::too_many_arguments)] + fn stage( + &self, + receipt_id: &str, + session_id: &str, + kind: CandidateKind, + claim: String, + why_useful: String, + staleness: Option, + entries: Vec, + proposal: Option, + ) -> Result { + if entries.is_empty() { + return Err(ToolError::ExecutionFailed( + "explicit staging requires committed evidence".into(), + )); + } + let evidence = entries.iter().map(staging_evidence).collect(); + let source_refs = entries.iter().map(source_evidence_ref).collect(); + let response = self.execute(SubjektivMemoryBackendOperation::StageExplicit( + SubjektivMemoryStageExplicitRequest { + receipt_id: receipt_id.to_string(), + session_id: session_id.to_string(), + kind, + claim, + why_useful, + staleness, + evidence, + source_refs, + proposal, + }, + ))?; + match response { + SubjektivMemoryBackendResponse::Staged(value) => Ok(value), + other => Err(ToolError::ExecutionFailed(format!( + "unexpected explicit staging response: {other:?}" + ))), + } + } +} + +struct PendingCommitHook { + state: SubjektivMemoryState, +} + +#[async_trait] +impl Hook for PendingCommitHook { + async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> { + let capture = + self.state.capture.capture().map_err(|error| { + HookError::new(HookErrorCategory::Dependency, error.to_string()) + })?; + let view = SessionCapture::from_history_entries( + capture.segment_id.clone(), + capture.history.clone(), + ); + let pending = self + .state + .pending + .lock() + .map_err(|_| HookError::new(HookErrorCategory::Internal, "pending queue poisoned"))? + .values() + .cloned() + .collect::>(); + let mut completed = Vec::new(); + let mut failures = Vec::new(); + for item in pending { + let Some(evidence) = view.evidence_for_tool_call(&item.call_id) else { + // The operation belongs to an uncommitted or different Session. + continue; + }; + match self.state.stage( + &item.receipt_id, + &capture.session_id, + item.kind.clone(), + item.claim, + item.why_useful, + item.staleness, + vec![evidence], + item.proposal, + ) { + Ok(_) => completed.push(item.receipt_id), + Err(error) => failures.push(error.to_string()), + } + } + if !completed.is_empty() { + let mut queue = self.state.pending.lock().map_err(|_| { + HookError::new(HookErrorCategory::Internal, "pending queue poisoned") + })?; + for receipt_id in completed { + queue.remove(&receipt_id); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(HookError::new( + HookErrorCategory::Dependency, + format!( + "explicit Memory staging after commit failed: {}", + failures.join("; ") + ), + )) + } + } +} + +fn resolve_entry_refs( + capture: &crate::feature::session::CommittedSessionCapture, + entry_refs: &[String], +) -> Result, ToolError> { + let view = + SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone()); + let mut seen = HashSet::new(); + entry_refs + .iter() + .map(|entry_ref| { + if !seen.insert(entry_ref) { + return Err(ToolError::InvalidArgument(format!( + "duplicate SessionEntryRef {entry_ref:?}" + ))); + } + view.evidence_for(entry_ref).ok_or_else(|| { + ToolError::InvalidArgument(format!( + "unknown SessionEntryRef {entry_ref:?} for the committed Session" + )) + }) + }) + .collect() +} + +fn ensure_preference_evidence( + kind: &CandidateKind, + entries: &[SessionEntryEvidence], +) -> Result<(), ToolError> { + if kind == &CandidateKind::Preference + && entries + .iter() + .any(|entry| !matches!(entry.origin, protocol::SessionEntryProvenance::HumanInput)) + { + return Err(ToolError::InvalidArgument( + "preference candidates require exclusively HumanInput evidence".into(), + )); + } + Ok(()) +} + +fn receipt_id(session_id: &str, call_id: &str) -> Result { + required(session_id, "Session id")?; + required(call_id, "tool call_id")?; + let value = format!("explicit:{session_id}:{call_id}"); + if value.len() > 512 || value.chars().any(char::is_control) { + return Err(ToolError::InvalidArgument( + "explicit Memory receipt identity is invalid".into(), + )); + } + Ok(value) +} + +fn pending_equivalent(left: &PendingExplicit, right: &PendingExplicit) -> bool { + left.receipt_id == right.receipt_id + && left.call_id == right.call_id + && left.kind == right.kind + && left.claim == right.claim + && left.why_useful == right.why_useful + && left.staleness == right.staleness + && left.proposal == right.proposal +} + +fn validate_candidate_text(claim: &str, why_useful: &str) -> Result<(), ToolError> { + required(claim, "claim")?; + required(why_useful, "why_useful")?; + Ok(()) +} + +fn required<'a>(value: &'a str, field: &str) -> Result<&'a str, ToolError> { + if value.trim().is_empty() || value.chars().any(char::is_control) { + Err(ToolError::InvalidArgument(format!("{field} is invalid"))) + } else { + Ok(value) + } +} + +fn parse(input: &str, tool: &str) -> Result { + serde_json::from_str(input) + .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) +} + +fn schema_for() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({})) +} + +fn staged_output(response: SubjektivMemoryStageExplicitResponse) -> Result { + if !matches!(response.status, SubjektivMemoryReceiptStatus::Staged) { + return Err(ToolError::ExecutionFailed( + "subjektiv backend returned a non-staged receipt".into(), + )); + } + let value = serde_json::to_value(&response) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + json_output( + format!( + "Staged explicit Memory candidate {}.", + response.candidate_id + ), + &value, + ) +} + +fn json_output( + summary: impl Into, + value: &serde_json::Value, +) -> Result { + Ok(ToolOutput { + summary: summary.into(), + content: Some( + serde_json::to_string_pretty(value) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, + ), + attachments: Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::feature::session::{CommittedRunExit, CommittedSessionCapture}; + use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + + #[derive(Debug)] + struct TestWorkspaceClient; + + impl WorkspaceClient for TestWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + _request: WorkspaceRequest, + ) -> Result { + Err(WorkspaceClientError::Request( + "test client has no backend".into(), + )) + } + } + + #[derive(Debug, Default)] + struct RecordingWorkspaceClient { + requests: Mutex>, + } + + impl WorkspaceClient for RecordingWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "recording-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&SubjektivMemoryBackendResponse::Staged( + SubjektivMemoryStageExplicitResponse { + candidate_id: "explicit:session-1:call-commit".into(), + receipt_id: "explicit:session-1:call-commit".into(), + status: SubjektivMemoryReceiptStatus::Staged, + target: None, + intent: None, + }, + )) + .unwrap(), + }) + } + } + + fn feature() -> SubjektivMemoryFeature { + let capture = CommittedSessionCaptureHandle::new(|| { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: 1, + entry_count: 0, + run_exit: CommittedRunExit::Finished, + history: Vec::new(), + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }); + SubjektivMemoryFeature { + state: SubjektivMemoryState { + client: Arc::new(TestWorkspaceClient), + capture, + pending: Arc::new(Mutex::new(HashMap::new())), + }, + } + } + + #[test] + fn descriptor_registers_exact_five_tools_and_commit_hook() { + let descriptor = feature().descriptor(); + let names = descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + QUERY_TOOL, + READ_TOOL, + LIST_REVISIONS_TOOL, + REMEMBER_TOOL, + PROPOSE_REVISION_TOOL, + ] + ); + assert_eq!(descriptor.hooks.len(), 1); + assert_eq!(descriptor.hooks[0].name, COMMIT_HOOK); + assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::RunCommitted); + } + + #[test] + fn model_schemas_exclude_host_scope_and_allow_optional_evidence_refs() { + let remember = schema_for::(); + let remember_text = serde_json::to_string(&remember).unwrap(); + assert!(!remember_text.contains("subject_id")); + assert!(!remember_text.contains("session_id")); + assert!(!remember_text.contains("origin")); + assert!( + !remember["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "entry_refs") + ); + + let propose = schema_for::(); + let propose_text = serde_json::to_string(&propose).unwrap(); + assert!(!propose_text.contains("subject_id")); + assert!(!propose_text.contains("\"kind\"")); + assert!(propose_text.contains("expected_revision")); + assert!(propose_text.contains("change_reason")); + + let parsed: RememberParams = serde_json::from_str( + r#"{"kind":"lesson","claim":"Remember this","why_useful":"Later recall"}"#, + ) + .unwrap(); + assert!(parsed.entry_refs.is_empty()); + } + + #[test] + fn receipt_identity_is_stable_and_rejects_invalid_values() { + assert_eq!( + receipt_id("session-1", "call-1").unwrap(), + "explicit:session-1:call-1" + ); + assert!(receipt_id("", "call-1").is_err()); + assert!(receipt_id("session-1", "bad\ncall").is_err()); + } + + #[tokio::test] + async fn remember_without_refs_is_pending_until_commit_and_idempotent_per_call() { + let feature = feature(); + let tool = SubjektivExplicitTool { + state: feature.state.clone(), + operation: ExplicitOperation::Remember, + }; + let input = + r#"{"kind":"lesson","claim":"Use fixed revisions","why_useful":"Avoid mixed reads"}"#; + let output = tool + .execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) + .await + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(value["status"], "pending_commit"); + assert_eq!(value["receipt_id"], "explicit:session-1:call-1"); + assert_eq!(feature.state.pending.lock().unwrap().len(), 1); + + tool.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) + .await + .unwrap(); + assert_eq!(feature.state.pending.lock().unwrap().len(), 1); + + let preference = tool + .execute( + r#"{"kind":"preference","claim":"I prefer X","why_useful":"Personalization"}"#, + ToolExecutionContext::new("call-2", "batch-1", 1), + ) + .await + .unwrap_err(); + assert!(preference.to_string().contains("HumanInput")); + } + + #[tokio::test] + async fn committed_tool_call_stages_pending_candidate_with_host_evidence() { + let client = Arc::new(RecordingWorkspaceClient::default()); + let capture = CommittedSessionCaptureHandle::new(|| { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: 2, + entry_count: 1, + run_exit: CommittedRunExit::Finished, + history: vec![crate::session_history::history_entry( + agen::Item::tool_call( + "call-commit", + REMEMBER_TOOL, + r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ModelOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + )], + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }); + let state = SubjektivMemoryState { + client: client.clone(), + capture, + pending: Arc::new(Mutex::new(HashMap::new())), + }; + let tool = SubjektivExplicitTool { + state: state.clone(), + operation: ExplicitOperation::Remember, + }; + tool.execute( + r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#, + ToolExecutionContext::new("call-commit", "batch-1", 0), + ) + .await + .unwrap(); + assert_eq!(state.pending.lock().unwrap().len(), 1); + + PendingCommitHook { + state: state.clone(), + } + .call(&RunCommittedContext { + invocation: crate::hook::HookInvocationContext::default(), + exit: crate::hook::RunCommittedExit::Finished, + committed_history: crate::hook::HookHistoryRange::default(), + }) + .await + .unwrap(); + assert!(state.pending.lock().unwrap().is_empty()); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body = requests[0].body.as_deref().unwrap(); + let request: SubjektivMemoryBackendRequest = serde_json::from_str(body).unwrap(); + let SubjektivMemoryBackendOperation::StageExplicit(staged) = request.operation else { + panic!("expected explicit stage request"); + }; + assert_eq!(staged.session_id, "session-1"); + assert_eq!(staged.evidence.len(), 1); + assert_eq!( + staged.evidence[0].origin.as_ref().unwrap().kind, + memory::schema::EvidenceOriginKind::ModelOutput + ); + assert_eq!( + staged.source_refs[0].evidence_id.as_deref(), + Some(staged.evidence[0].id.as_str()) + ); + } +} diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index 335af3852..86c9fb911 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -600,6 +600,28 @@ impl SessionCapture { ReadResult { entries, truncated } } + /// Resolves the committed tool-call input for a provider call id. This is + /// used by explicit-memory receipts only after the containing run is durable, + /// so a model-authored claim can cite its own committed tool invocation + /// without inventing a SessionEntryRef. + pub(crate) fn evidence_for_tool_call(&self, call_id: &str) -> Option { + let entry_index = self.entries.iter().position(|entry| { + matches!( + &entry.item, + Item::ToolCall { + call_id: stored_call_id, + .. + } if stored_call_id == call_id + ) + })?; + let reference = self.index.iter().find(|entry| { + entry.entry_range[0] == entry_index as u64 + && entry.kind == ReferenceKind::Tool + && entry.tool_part == Some(ToolPart::Input) + })?; + self.evidence_for(reference.id.as_str()) + } + pub(crate) fn evidence_for(&self, id: &str) -> Option { let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?; let excerpt = self @@ -1028,4 +1050,20 @@ mod tests { assert_eq!(source.entry_range, [0, 0]); assert_eq!(source.entry_ref.as_str(), "E00000000"); } + + #[test] + fn committed_tool_call_can_be_resolved_by_provider_call_id() { + let view = SessionCapture::new( + "segment-1", + vec![Item::tool_call( + "call-remember", + "SubjektivMemoryRemember", + r#"{"claim":"remember this"}"#, + )], + ); + let evidence = view.evidence_for_tool_call("call-remember").unwrap(); + assert_eq!(evidence.entry_ref.as_str(), "E00000000"); + assert_eq!(evidence.tool_part, Some(ToolPart::Input)); + assert!(view.evidence_for_tool_call("missing").is_none()); + } } diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index b9e42ec4e..b8ad139db 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -626,6 +626,7 @@ pub enum WorkspaceServerOperation { WorkerObservationCapture(server_api::WorkerObservationSubjectRef), SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), + SubjektivMemory(server_api::SubjektivMemoryBackendRequest), } fn workspace_server_json_request( @@ -710,6 +711,9 @@ fn workspace_server_operation_request( WorkspaceServerOperation::SubjektivRecordSession(request) => { workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request) } + WorkspaceServerOperation::SubjektivMemory(request) => { + workspace_server_json_request(format!("{base}/subjektiv/memory"), &request) + } } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 34e251b37..ec8d813ab 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5456,6 +5456,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_record_session( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_memory_backend( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -7143,6 +7146,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_memory_backend( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::SubjektivMemoryBackendRequest, + ) -> std::result::Result< + server_api::SubjektivMemoryBackendResponse, + server_api::RepositoryApiError, + > { + scoped_subjektiv_memory_backend( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn skill_list( &self, workspace_id: String, @@ -16526,21 +16549,19 @@ fn subjektiv_singleton_key(subject_id: &str) -> ApiResult { Ok(key) } -fn subjektiv_session_attribution( +fn subjektiv_subject_scope( api: &WorkspaceApi, workspace_id: &str, context: &server_api::ServerRequestContext, - session_id: &str, -) -> ApiResult { +) -> ApiResult<(String, RuntimeWorkerRef)> { let source = context.runtime_source.as_ref().ok_or_else(|| { Error::WorkspacePermissionDenied( - "subjektiv extraction requires authenticated Runtime-owned source authority" - .to_string(), + "subjektiv operations require authenticated Runtime-owned source authority".to_string(), ) })?; let worker_id = source.worker_id.as_deref().ok_or_else(|| { Error::WorkspacePermissionDenied( - "subjektiv extraction requires Runtime-bound Worker identity".to_string(), + "subjektiv operations require Runtime-bound Worker identity".to_string(), ) })?; let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); @@ -16552,7 +16573,17 @@ fn subjektiv_session_attribution( "current Worker has no keyed singleton ownership".to_string(), ) })?; - let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?; + let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?.to_string(); + Ok((subject_id, worker)) +} + +fn subjektiv_session_attribution( + api: &WorkspaceApi, + workspace_id: &str, + context: &server_api::ServerRequestContext, + session_id: &str, +) -> ApiResult { + let (subject_id, worker) = subjektiv_subject_scope(api, workspace_id, context)?; let session_id = session_id.trim(); if session_id.is_empty() || session_id.chars().any(char::is_control) { return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into()); @@ -16740,6 +16771,749 @@ async fn scoped_record_subjektiv_session( })) } +const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; +const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; +const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; +const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; +const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivQueryCursor { + subject_id: String, + store_revision: u64, + query: Option, + kinds: Vec, + states: Vec, + offset: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivRevisionCursor { + subject_id: String, + memory_id: String, + max_revision: u64, + offset: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivEvidenceCursor { + subject_id: String, + memory_id: String, + revision: u64, + offset: usize, +} + +async fn scoped_subjektiv_memory_backend( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, _) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + let store = open_subjektiv_store(&api)?; + let response = match request.operation { + server_api::SubjektivMemoryBackendOperation::Query(input) => { + server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( + &store, + &subject_id, + input, + )?) + } + server_api::SubjektivMemoryBackendOperation::Read(input) => { + server_api::SubjektivMemoryBackendResponse::Read(subjektiv_memory_read( + &store, + &subject_id, + input, + )?) + } + server_api::SubjektivMemoryBackendOperation::ListRevisions(input) => { + server_api::SubjektivMemoryBackendResponse::ListRevisions( + subjektiv_memory_list_revisions(&store, &subject_id, input)?, + ) + } + server_api::SubjektivMemoryBackendOperation::ValidateProposal(input) => { + server_api::SubjektivMemoryBackendResponse::ProposalValidated( + subjektiv_memory_validate_proposal(&store, &subject_id, input)?, + ) + } + server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => { + // Re-derive the same subject and immutable Session attribution for the + // write. The request never carries subject, Worker, Runtime, origin, + // or arbitrary source JSON from the model. + let attribution = subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &input.session_id, + )?; + server_api::SubjektivMemoryBackendResponse::Staged(subjektiv_memory_stage_explicit( + &store, + input, + attribution, + )?) + } + }; + Ok(Json(response)) +} + +fn subjektiv_memory_query( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryQueryRequest, +) -> ApiResult { + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let query = input + .query + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()); + let kinds = canonical_subjektiv_kinds(input.kinds)?; + let states = canonical_subjektiv_states(input.states)?; + let subject = store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.to_string()))?; + let mut offset = 0; + if let Some(cursor) = input.cursor { + let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; + if cursor.subject_id != subject_id + || cursor.query != query + || cursor.kinds != kinds + || cursor.states != states + { + return Err(Error::InvalidInput( + "subjektiv query cursor does not match subject or filters".into(), + ) + .into()); + } + if cursor.store_revision != subject.store_revision { + return Err(Error::RepositoryConflict( + "subjektiv query cursor is stale because Memory changed; restart the query".into(), + ) + .into()); + } + offset = cursor.offset; + } + + let kind_filter = kinds.iter().cloned().collect::>(); + let state_filter = states.iter().cloned().collect::>(); + let records = store + .list_memories(subject_id) + .map_err(subjektiv_store_error)? + .into_iter() + .filter(|record| { + kind_filter.contains(record.kind.as_str()) + && state_filter.contains(subjektiv_memory_state_name(record.state)) + && query.as_ref().is_none_or(|query| { + record.claim.to_lowercase().contains(query) + || record.body_md.to_lowercase().contains(query) + }) + }) + .collect::>(); + if offset > records.len() { + return Err(Error::InvalidInput("subjektiv query cursor offset is invalid".into()).into()); + } + let end = offset.saturating_add(limit).min(records.len()); + let has_more = end < records.len(); + let items = records[offset..end] + .iter() + .map(|record| server_api::SubjektivMemoryQueryItem { + id: record.id.clone(), + revision: record.revision, + kind: record.kind.clone(), + state: api_memory_state(record.state), + claim: record.claim.clone(), + excerpt: bounded_memory_excerpt(&record.body_md, 240), + updated_at: record.updated_at.clone(), + }) + .collect(); + let next_cursor = has_more + .then(|| { + encode_subjektiv_cursor( + "query", + &SubjektivQueryCursor { + subject_id: subject_id.to_string(), + store_revision: subject.store_revision, + query, + kinds, + states, + offset: end, + }, + ) + }) + .transpose()?; + Ok(server_api::SubjektivMemoryQueryResponse { + items, + next_cursor, + has_more, + }) +} + +fn subjektiv_memory_read( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryReadRequest, +) -> ApiResult { + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + let record = match input.revision { + Some(0) => { + return Err(Error::InvalidInput("revision must be a positive integer".into()).into()); + } + Some(revision) => store + .scoped_memory_revision(subject_id, &input.memory_id, revision) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + Error::InvalidInput(format!( + "memory_revision_not_found: Memory `{}` has no revision {revision}", + input.memory_id + )) + })?, + None => current.clone(), + }; + let body_limit = input.limit.unwrap_or(SUBJEKTIV_BODY_DEFAULT_LINES); + if body_limit == 0 || body_limit > SUBJEKTIV_BODY_MAX_LINES { + return Err(Error::InvalidInput(format!( + "body line limit must be within 1..={SUBJEKTIV_BODY_MAX_LINES}" + )) + .into()); + } + let body_offset = input.offset.unwrap_or(0); + let lines = memory_body_lines(&record.body_md); + if body_offset > lines.len() { + return Err(Error::InvalidInput("body line offset exceeds document length".into()).into()); + } + let body_end = body_offset.saturating_add(body_limit).min(lines.len()); + let body_truncated = body_end < lines.len(); + let body_md = lines[body_offset..body_end].concat(); + + let evidence_offset = if let Some(cursor) = input.evidence_cursor { + let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; + if cursor.subject_id != subject_id + || cursor.memory_id != record.id + || cursor.revision != record.revision + { + return Err(Error::InvalidInput( + "subjektiv evidence cursor does not match subject, Memory, or revision".into(), + ) + .into()); + } + cursor.offset + } else { + 0 + }; + let total_evidence = record + .source_candidate_ids + .len() + .saturating_add(record.derived_from.len()); + if evidence_offset > total_evidence { + return Err( + Error::InvalidInput("subjektiv evidence cursor offset is invalid".into()).into(), + ); + } + let evidence_end = evidence_offset + .saturating_add(SUBJEKTIV_EVIDENCE_PAGE_SIZE) + .min(total_evidence); + let mut source_candidate_ids = Vec::new(); + let mut source_candidates = Vec::new(); + let mut derived_from = Vec::new(); + for index in evidence_offset..evidence_end { + if let Some(candidate_id) = record.source_candidate_ids.get(index) { + let candidate = store + .staging_candidate(subject_id, candidate_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + Error::Store(format!( + "subjektiv candidate provenance `{candidate_id}` is missing" + )) + })?; + source_candidate_ids.push(candidate_id.clone()); + source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { + candidate_id: candidate.id, + evidence: candidate.evidence, + source_refs: candidate.source_refs, + }); + } else { + let derived_index = index - record.source_candidate_ids.len(); + let reference = &record.derived_from[derived_index]; + derived_from.push(server_api::SubjektivMemoryRevisionRef { + memory_id: reference.memory_id.clone(), + revision: reference.revision, + }); + } + } + let evidence_has_more = evidence_end < total_evidence; + let evidence_next_cursor = evidence_has_more + .then(|| { + encode_subjektiv_cursor( + "evidence", + &SubjektivEvidenceCursor { + subject_id: subject_id.to_string(), + memory_id: record.id.clone(), + revision: record.revision, + offset: evidence_end, + }, + ) + }) + .transpose()?; + + Ok(server_api::SubjektivMemoryReadResponse { + memory_id: record.id, + revision: record.revision, + current_revision: current.revision, + kind: record.kind, + state: api_memory_state(record.state), + claim: record.claim, + body_md, + why_useful: record.why_useful, + staleness: record.staleness, + change_reason: record.change_reason, + created_at: record.created_at, + updated_at: record.updated_at, + body_offset, + body_next_offset: body_truncated.then_some(body_end), + body_truncated, + source_candidate_ids, + source_candidates, + derived_from, + evidence_next_cursor, + evidence_has_more, + }) +} + +fn subjektiv_memory_list_revisions( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryListRevisionsRequest, +) -> ApiResult { + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + let (max_revision, offset) = if let Some(cursor) = input.cursor { + let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; + if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { + return Err(Error::InvalidInput( + "subjektiv revision cursor does not match subject or Memory".into(), + ) + .into()); + } + (cursor.max_revision, cursor.offset) + } else { + (current.revision, 0) + }; + let records = store + .list_memory_revisions(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)? + .into_iter() + .filter(|record| record.revision <= max_revision) + .collect::>(); + if offset > records.len() { + return Err( + Error::InvalidInput("subjektiv revision cursor offset is invalid".into()).into(), + ); + } + let end = offset.saturating_add(limit).min(records.len()); + let has_more = end < records.len(); + let items = records[offset..end] + .iter() + .map(|record| server_api::SubjektivMemoryRevisionItem { + revision: record.revision, + kind: record.kind.clone(), + state: api_memory_state(record.state), + claim: record.claim.clone(), + change_reason: record.change_reason.clone(), + updated_at: record.updated_at.clone(), + }) + .collect(); + let next_cursor = has_more + .then(|| { + encode_subjektiv_cursor( + "revisions", + &SubjektivRevisionCursor { + subject_id: subject_id.to_string(), + memory_id: input.memory_id.clone(), + max_revision, + offset: end, + }, + ) + }) + .transpose()?; + Ok(server_api::SubjektivMemoryListRevisionsResponse { + memory_id: input.memory_id, + current_revision: current.revision, + items, + next_cursor, + has_more, + }) +} + +fn subjektiv_memory_validate_proposal( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryValidateProposalRequest, +) -> ApiResult { + if input.expected_revision == 0 { + return Err(Error::InvalidInput("expected_revision must be positive".into()).into()); + } + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + if current.revision != input.expected_revision { + return Err(Error::RepositoryConflict(format!( + "revision_conflict: Memory `{}` expected {}, current {}", + input.memory_id, input.expected_revision, current.revision + )) + .into()); + } + validate_subjektiv_proposal_transition(current.state, input.intent)?; + Ok(server_api::SubjektivMemoryProposalValidationResponse { + memory_id: input.memory_id, + current_revision: current.revision, + kind: current.kind, + state: api_memory_state(current.state), + intent: input.intent, + }) +} + +fn subjektiv_memory_stage_explicit( + store: &crate::subjektiv::SubjektivStore, + mut input: server_api::SubjektivMemoryStageExplicitRequest, + attribution: crate::subjektiv::SubjectSessionAttribution, +) -> ApiResult { + let receipt_id = input.receipt_id.trim(); + if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) { + return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into()); + } + if input.evidence.is_empty() || input.source_refs.is_empty() { + return Err(Error::InvalidInput( + "explicit Memory staging requires host-resolved committed evidence".into(), + ) + .into()); + } + if matches!(input.kind, memory::extract::CandidateKind::Preference) + && input.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) + { + return Err(Error::InvalidInput( + "preference candidates require exclusively HumanInput evidence".into(), + ) + .into()); + } + let mut range_start = u64::MAX; + let mut range_end = 0; + let mut segment_id = None; + for source_ref in &mut input.source_refs { + if source_ref + .session_id + .as_deref() + .is_some_and(|value| value != attribution.session_id) + { + return Err(Error::InvalidInput( + "explicit candidate source belongs to a different Session".into(), + ) + .into()); + } + source_ref.session_id = Some(attribution.session_id.clone()); + let source_segment = source_ref.segment_id.as_deref().ok_or_else(|| { + Error::InvalidInput("explicit candidate source is missing segment_id".into()) + })?; + if segment_id + .as_deref() + .is_some_and(|value| value != source_segment) + { + return Err(Error::InvalidInput( + "explicit candidate evidence spans multiple Session segments".into(), + ) + .into()); + } + segment_id = Some(source_segment.to_string()); + let range = source_ref.entry_range.ok_or_else(|| { + Error::InvalidInput("explicit candidate source is missing entry_range".into()) + })?; + range_start = range_start.min(range[0]); + range_end = range_end.max(range[1]); + } + let source = memory::schema::SourceRef { + segment_id: segment_id.ok_or_else(|| { + Error::InvalidInput("explicit candidate source is missing segment_id".into()) + })?, + range: [range_start, range_end], + }; + let proposal = input + .proposal + .as_ref() + .map(|proposal| { + crate::subjektiv::RevisionProposal::new( + domain_proposal_intent(proposal.intent), + proposal.memory_id.clone(), + proposal.expected_revision, + proposal.change_reason.clone(), + ) + }) + .transpose() + .map_err(subjektiv_store_error)?; + if let Some(proposal) = &proposal { + let current = store + .scoped_memory(&attribution.subject_id, &proposal.memory_id) + .map_err(subjektiv_store_error)?; + if current.kind != input.kind { + return Err(Error::InvalidInput( + "revision proposal kind must be derived from its target Memory".into(), + ) + .into()); + } + } + let candidate = memory::extract::ExtractedCandidate { + kind: input.kind, + claim: input.claim, + why_useful: input.why_useful, + staleness: input.staleness, + evidence_ids: input.evidence.iter().map(|item| item.id.clone()).collect(), + }; + let staging = memory::extract::StagingRecord::from_candidate( + receipt_id, + format!("explicit:{receipt_id}"), + source, + candidate, + input.evidence, + input.source_refs, + ); + let mut staging = + crate::subjektiv::SubjectStagingRecord::attach(&attribution.subject_id, staging); + if let Some(proposal) = proposal.clone() { + staging + .attach_revision_proposal(proposal) + .map_err(subjektiv_store_error)?; + } + let (staged, _) = store + .stage_candidate_with_attribution(staging, attribution) + .map_err(subjektiv_store_error)?; + Ok(server_api::SubjektivMemoryStageExplicitResponse { + candidate_id: staged.id, + receipt_id: receipt_id.to_string(), + status: server_api::SubjektivMemoryReceiptStatus::Staged, + target: proposal + .as_ref() + .map(|proposal| server_api::SubjektivMemoryProposalTarget { + memory_id: proposal.memory_id.clone(), + expected_revision: proposal.expected_revision, + }), + intent: proposal + .as_ref() + .map(|proposal| api_proposal_intent(proposal.intent)), + }) +} + +fn validate_subjektiv_proposal_transition( + state: crate::subjektiv::MemoryState, + intent: server_api::SubjektivMemoryRevisionIntent, +) -> ApiResult<()> { + use crate::subjektiv::MemoryState::{Active, Resolved}; + use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise}; + let valid = match intent { + Revise => matches!(state, Active | Resolved), + Resolve => state == Active, + Retract => matches!(state, Active | Resolved), + Reopen => state == Resolved, + }; + if valid { + Ok(()) + } else { + Err(Error::InvalidInput(format!( + "invalid_state_transition: {state:?} cannot accept {intent:?}" + )) + .into()) + } +} + +fn canonical_subjektiv_kinds( + kinds: Option>, +) -> ApiResult> { + let kinds = kinds.unwrap_or_else(|| { + use memory::extract::CandidateKind::*; + vec![ + Preference, + WorkingAssumption, + Constraint, + Decision, + OpenQuestion, + Lesson, + ] + }); + if kinds.is_empty() { + return Err(Error::InvalidInput("kinds must not be an empty array".into()).into()); + } + let mut names = kinds + .iter() + .map(|kind| kind.as_str().to_string()) + .collect::>(); + names.sort(); + names.dedup(); + Ok(names) +} + +fn canonical_subjektiv_states( + states: Option>, +) -> ApiResult> { + let states = states.unwrap_or_else(|| vec![server_api::SubjektivMemoryState::Active]); + if states.is_empty() { + return Err(Error::InvalidInput("states must not be an empty array".into()).into()); + } + let mut names = states + .into_iter() + .map(|state| match state { + server_api::SubjektivMemoryState::Active => "active".to_string(), + server_api::SubjektivMemoryState::Resolved => "resolved".to_string(), + server_api::SubjektivMemoryState::Retracted => "retracted".to_string(), + }) + .collect::>(); + names.sort(); + names.dedup(); + Ok(names) +} + +fn bounded_subjektiv_limit(limit: Option, default: usize) -> ApiResult { + let limit = limit.unwrap_or(default); + if limit == 0 || limit > SUBJEKTIV_QUERY_MAX_LIMIT { + return Err(Error::InvalidInput(format!( + "limit must be within 1..={SUBJEKTIV_QUERY_MAX_LIMIT}" + )) + .into()); + } + Ok(limit) +} + +fn api_memory_state(state: crate::subjektiv::MemoryState) -> server_api::SubjektivMemoryState { + match state { + crate::subjektiv::MemoryState::Active => server_api::SubjektivMemoryState::Active, + crate::subjektiv::MemoryState::Resolved => server_api::SubjektivMemoryState::Resolved, + crate::subjektiv::MemoryState::Retracted => server_api::SubjektivMemoryState::Retracted, + } +} + +fn subjektiv_memory_state_name(state: crate::subjektiv::MemoryState) -> &'static str { + match state { + crate::subjektiv::MemoryState::Active => "active", + crate::subjektiv::MemoryState::Resolved => "resolved", + crate::subjektiv::MemoryState::Retracted => "retracted", + } +} + +fn domain_proposal_intent( + intent: server_api::SubjektivMemoryRevisionIntent, +) -> crate::subjektiv::RevisionProposalIntent { + match intent { + server_api::SubjektivMemoryRevisionIntent::Revise => { + crate::subjektiv::RevisionProposalIntent::Revise + } + server_api::SubjektivMemoryRevisionIntent::Resolve => { + crate::subjektiv::RevisionProposalIntent::Resolve + } + server_api::SubjektivMemoryRevisionIntent::Retract => { + crate::subjektiv::RevisionProposalIntent::Retract + } + server_api::SubjektivMemoryRevisionIntent::Reopen => { + crate::subjektiv::RevisionProposalIntent::Reopen + } + } +} + +fn api_proposal_intent( + intent: crate::subjektiv::RevisionProposalIntent, +) -> server_api::SubjektivMemoryRevisionIntent { + match intent { + crate::subjektiv::RevisionProposalIntent::Revise => { + server_api::SubjektivMemoryRevisionIntent::Revise + } + crate::subjektiv::RevisionProposalIntent::Resolve => { + server_api::SubjektivMemoryRevisionIntent::Resolve + } + crate::subjektiv::RevisionProposalIntent::Retract => { + server_api::SubjektivMemoryRevisionIntent::Retract + } + crate::subjektiv::RevisionProposalIntent::Reopen => { + server_api::SubjektivMemoryRevisionIntent::Reopen + } + } +} + +fn memory_body_lines(body: &str) -> Vec<&str> { + if body.is_empty() { + Vec::new() + } else { + body.split_inclusive('\n').collect() + } +} + +fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String { + let normalized = body.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let excerpt = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{excerpt}…") + } else { + excerpt + } +} + +fn encode_subjektiv_cursor(kind: &str, value: &T) -> ApiResult { + let raw = serde_json::to_vec(value) + .map_err(|error| Error::Store(format!("encode subjektiv cursor: {error}")))?; + let encoded = raw + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(format!("subjektiv.{kind}.{encoded}")) +} + +fn decode_subjektiv_cursor( + kind: &str, + cursor: &str, +) -> ApiResult { + let prefix = format!("subjektiv.{kind}."); + let encoded = cursor + .strip_prefix(&prefix) + .ok_or_else(|| Error::InvalidInput(format!("invalid subjektiv {kind} cursor type")))?; + if encoded.is_empty() || encoded.len() > 16_384 || encoded.len() % 2 != 0 { + return Err(Error::InvalidInput(format!("invalid subjektiv {kind} cursor")).into()); + } + let mut raw = Vec::with_capacity(encoded.len() / 2); + for index in (0..encoded.len()).step_by(2) { + raw.push( + u8::from_str_radix(&encoded[index..index + 2], 16).map_err(|_| { + Error::InvalidInput(format!("invalid subjektiv {kind} cursor encoding")) + })?, + ); + } + serde_json::from_slice(&raw) + .map_err(|_| Error::InvalidInput(format!("invalid subjektiv {kind} cursor payload")).into()) +} + +fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { + match error { + crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => { + Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) + } + crate::subjektiv::SubjektivError::RevisionConflict { .. } => { + Error::RepositoryConflict(format!("revision_conflict: {error}")) + } + crate::subjektiv::SubjektivError::MemoryNotFound(_) => { + Error::InvalidInput(format!("memory_not_found: {error}")) + } + crate::subjektiv::SubjektivError::Storage(_) => Error::Store(error.to_string()), + _ => Error::InvalidInput(error.to_string()), + } +} + const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; @@ -34143,7 +34917,7 @@ mod tests { AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), }), - context, + context.clone(), Json(server_api::SubjektivStageCandidateRequest { session_id: "session-1".to_string(), operation, @@ -34164,6 +34938,255 @@ mod tests { let attribution = store.session_attribution("session-1").unwrap().unwrap(); assert_eq!(attribution.subject_id, subject.id); assert_eq!(attribution.worker_id, created.worker_id); + + let target = store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Use the first rule", + "first body", + "Provides a target", + "initial", + ), + ) + .unwrap(); + let explicit_evidence = memory::extract::StagingEvidence { + id: "E00000000".to_string(), + kind: memory::schema::EvidenceKind::new(memory::schema::EvidenceKind::MESSAGE), + entry_range: Some([0, 0]), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::HumanInput, + account_id: None, + workspace_id: None, + runtime_id: None, + worker_id: None, + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }), + excerpt: Some("Correct the first rule".to_string()), + summary: Some("Human correction".to_string()), + }; + let Json(explicit) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context, + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::StageExplicit( + server_api::SubjektivMemoryStageExplicitRequest { + receipt_id: "explicit:session-1:call-1".to_string(), + session_id: "session-1".to_string(), + kind: memory::extract::CandidateKind::Decision, + claim: "Use the corrected rule".to_string(), + why_useful: "Records a correction proposal".to_string(), + staleness: None, + evidence: vec![explicit_evidence], + source_refs: vec![memory::schema::SourceEvidenceRef { + segment_id: Some("segment-1".to_string()), + entry_range: Some([0, 0]), + evidence_id: Some("E00000000".to_string()), + ..Default::default() + }], + proposal: Some(server_api::SubjektivMemoryRevisionProposal { + memory_id: target.id.clone(), + expected_revision: 1, + intent: server_api::SubjektivMemoryRevisionIntent::Revise, + change_reason: "The human corrected the rule".to_string(), + }), + }, + ), + }), + ) + .await + .unwrap(); + let staged = match explicit { + server_api::SubjektivMemoryBackendResponse::Staged(staged) => staged, + other => panic!("unexpected explicit response: {other:?}"), + }; + assert_eq!(staged.candidate_id, "explicit:session-1:call-1"); + let candidate = store + .staging_candidate(&subject.id, &staged.candidate_id) + .unwrap() + .unwrap(); + assert_eq!(candidate.revision_proposal.unwrap().memory_id, target.id); + assert_eq!( + store + .memory(&subject.id, &target.id) + .unwrap() + .unwrap() + .revision, + 1, + "staging a proposal must not create a confirmed revision" + ); + } + + #[tokio::test] + async fn subjektiv_memory_tools_filter_page_and_read_fixed_revisions() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let store = open_subjektiv_store(&api).unwrap(); + let subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let first = store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Choose stable cursors", + "line one\nline two\nline three\n", + "Prevents mixed snapshots", + "initial", + ), + ) + .unwrap(); + let second = store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep immutable history", + "old body\n", + "Supports correction", + "initial", + ), + ) + .unwrap(); + let mut resolved = crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "History was retained", + "new body\n", + "The question ended", + "resolved after verification", + ); + resolved.state = crate::subjektiv::MemoryState::Resolved; + store + .revise_memory(&subject.id, &second.id, 1, resolved) + .unwrap(); + + let default_page = subjektiv_memory_query( + &store, + &subject.id, + server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: None, + limit: Some(1), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(default_page.items.len(), 1); + assert_eq!(default_page.items[0].id, first.id); + assert!(!default_page.has_more); + + let resolved_page = subjektiv_memory_query( + &store, + &subject.id, + server_api::SubjektivMemoryQueryRequest { + query: Some("new body".into()), + kinds: None, + states: Some(vec![server_api::SubjektivMemoryState::Resolved]), + limit: None, + cursor: None, + }, + ) + .unwrap(); + assert_eq!(resolved_page.items.len(), 1); + assert_eq!(resolved_page.items[0].id, second.id); + + let snapshot_page = subjektiv_memory_query( + &store, + &subject.id, + server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: Some(vec![ + server_api::SubjektivMemoryState::Active, + server_api::SubjektivMemoryState::Resolved, + ]), + limit: Some(1), + cursor: None, + }, + ) + .unwrap(); + assert!(snapshot_page.has_more); + store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::OpenQuestion, + "Will this stale the cursor?", + "yes", + "Tests snapshot behavior", + "initial", + ), + ) + .unwrap(); + let stale = subjektiv_memory_query( + &store, + &subject.id, + server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: Some(vec![ + server_api::SubjektivMemoryState::Active, + server_api::SubjektivMemoryState::Resolved, + ]), + limit: Some(1), + cursor: snapshot_page.next_cursor, + }, + ) + .unwrap_err(); + assert!(matches!(stale.error, Error::RepositoryConflict(_))); + + let read = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: first.id.clone(), + revision: Some(1), + offset: Some(1), + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap(); + assert_eq!(read.body_md, "line two\n"); + assert_eq!(read.body_next_offset, Some(2)); + assert!(read.body_truncated); + assert_eq!(read.revision, 1); + assert_eq!(read.current_revision, 1); + + let revisions = subjektiv_memory_list_revisions( + &store, + &subject.id, + server_api::SubjektivMemoryListRevisionsRequest { + memory_id: second.id.clone(), + limit: Some(1), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(revisions.items[0].revision, 2); + assert!(revisions.has_more); + let older = subjektiv_memory_list_revisions( + &store, + &subject.id, + server_api::SubjektivMemoryListRevisionsRequest { + memory_id: second.id, + limit: Some(1), + cursor: revisions.next_cursor, + }, + ) + .unwrap(); + assert_eq!(older.items[0].revision, 1); + assert_eq!(older.items[0].claim, "Keep immutable history"); + assert!(!older.has_more); } #[tokio::test] diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 639b12a66..17bf71d3c 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -161,8 +161,74 @@ impl SubjectSessionAttribution { } } -/// The existing extraction schema with only host-owned subject scope and -/// persistence time added. Model input still contains neither field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevisionProposalIntent { + Revise, + Resolve, + Retract, + Reopen, +} + +/// Host-owned metadata describing how a staged candidate is expected to change +/// one existing Memory. It is validated against the current scoped Memory when +/// the candidate is staged; it is not model-authored and does not itself apply +/// the revision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RevisionProposal { + pub intent: RevisionProposalIntent, + pub memory_id: String, + pub expected_revision: u64, + pub change_reason: String, +} + +impl RevisionProposal { + pub fn new( + intent: RevisionProposalIntent, + memory_id: impl Into, + expected_revision: u64, + change_reason: impl Into, + ) -> Result { + let proposal = Self { + intent, + memory_id: memory_id.into(), + expected_revision, + change_reason: change_reason.into(), + }; + validate_revision_proposal_metadata(&proposal)?; + Ok(proposal) + } +} + +impl<'de> Deserialize<'de> for RevisionProposal { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct WireProposal { + intent: RevisionProposalIntent, + memory_id: String, + expected_revision: u64, + change_reason: String, + } + + let proposal = WireProposal::deserialize(deserializer)?; + RevisionProposal::new( + proposal.intent, + proposal.memory_id, + proposal.expected_revision, + proposal.change_reason, + ) + .map_err(serde::de::Error::custom) + } +} + +/// The existing extraction schema with only host-owned subject scope, +/// persistence time, and optional revision proposal metadata added. Model input +/// still contains none of those fields. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SubjectStagingRecord { @@ -180,6 +246,8 @@ pub struct SubjectStagingRecord { pub evidence: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision_proposal: Option, pub created_at: String, } @@ -203,9 +271,24 @@ impl SubjectStagingRecord { staleness: record.staleness, evidence: record.evidence, source_refs: record.source_refs, + revision_proposal: None, created_at, } } + + /// Attaches host-owned revision proposal metadata to this candidate. + pub fn with_revision_proposal(mut self, proposal: RevisionProposal) -> Result { + validate_revision_proposal_metadata(&proposal)?; + self.revision_proposal = Some(proposal); + Ok(self) + } + + /// Mutably attaches host-owned revision proposal metadata to this candidate. + pub fn attach_revision_proposal(&mut self, proposal: RevisionProposal) -> Result<()> { + validate_revision_proposal_metadata(&proposal)?; + self.revision_proposal = Some(proposal); + Ok(()) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1119,6 +1202,95 @@ impl SubjektivStore { }) } + /// Reads one current Memory while distinguishing an absent target from a + /// Memory id owned by another subject. + pub fn scoped_memory(&self, subject_id: &str, memory_id: &str) -> Result { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id) + }) + } + + /// Reads one immutable revision after verifying that the Memory belongs to + /// the requested subject. A missing revision of a valid scoped Memory is + /// returned as `None`. + pub fn scoped_memory_revision( + &self, + subject_id: &str, + memory_id: &str, + revision: u64, + ) -> Result> { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + if revision == 0 { + return Err(SubjektivError::InvalidRecord( + "Memory revision must be greater than zero".into(), + )); + } + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id)?; + let raw = connection + .query_row( + "SELECT record_json FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", + params![subject_id, memory_id, to_i64(revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_memory(&raw)).transpose() + }) + } + + /// Lists current Memory records for one existing subject in deterministic + /// most-recently-updated order. + pub fn list_memories(&self, subject_id: &str) -> Result> { + validate_label("subject id", subject_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 + ORDER BY updated_at DESC, memory_id ASC", + )?; + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_memory(&row?)?); + } + Ok(records) + }) + } + + /// Lists every immutable revision of one scoped Memory, newest first. + pub fn list_memory_revisions( + &self, + subject_id: &str, + memory_id: &str, + ) -> Result> { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 + ORDER BY revision DESC", + )?; + let rows = statement.query_map(params![subject_id, memory_id], |row| { + row.get::<_, String>(0) + })?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_memory(&row?)?); + } + Ok(records) + }) + } + pub fn create_surface_snapshot( &self, subject_id: &str, @@ -1382,6 +1554,7 @@ fn write_staging_candidate( } return Err(SubjektivError::CandidateConflict(record.id.clone())); } + validate_staged_revision_proposal(transaction, &record)?; transaction.execute( "INSERT INTO staging_records ( subject_id, candidate_id, kind, record_json, created_at @@ -1571,7 +1744,14 @@ fn require_unresolved_candidate( } fn require_subject(transaction: &Transaction<'_>, subject_id: &str) -> Result { - let raw = transaction + require_subject_in_connection(transaction, subject_id) +} + +fn require_subject_in_connection( + connection: &rusqlite::Connection, + subject_id: &str, +) -> Result { + let raw = connection .query_row( "SELECT record_json FROM subjects WHERE subject_id = ?1", [subject_id], @@ -1774,9 +1954,98 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> }); } } + if let Some(proposal) = &record.revision_proposal { + validate_revision_proposal_metadata(proposal)?; + } Ok(()) } +fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> { + validate_label("revision proposal Memory id", &proposal.memory_id)?; + if proposal.expected_revision == 0 { + return Err(SubjektivError::InvalidRecord( + "revision proposal expected_revision must be greater than zero".into(), + )); + } + validate_nonempty("revision proposal change_reason", &proposal.change_reason) +} + +fn validate_staged_revision_proposal( + transaction: &Transaction<'_>, + record: &SubjectStagingRecord, +) -> Result<()> { + let Some(proposal) = &record.revision_proposal else { + return Ok(()); + }; + let current = + scoped_memory_in_connection(transaction, &record.subject_id, &proposal.memory_id)?; + if current.revision != proposal.expected_revision { + return Err(SubjektivError::RevisionConflict { + memory_id: proposal.memory_id.clone(), + expected: proposal.expected_revision, + actual: current.revision, + }); + } + let to = match proposal.intent { + RevisionProposalIntent::Revise => current.state, + RevisionProposalIntent::Resolve => MemoryState::Resolved, + RevisionProposalIntent::Retract => MemoryState::Retracted, + RevisionProposalIntent::Reopen => MemoryState::Active, + }; + let allowed = match proposal.intent { + RevisionProposalIntent::Revise => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Resolve => current.state == MemoryState::Active, + RevisionProposalIntent::Retract => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + }; + if allowed { + Ok(()) + } else { + Err(SubjektivError::InvalidStateTransition { + memory_id: proposal.memory_id.clone(), + from: current.state, + to, + }) + } +} + +fn scoped_memory_in_connection( + connection: &rusqlite::Connection, + subject_id: &str, + memory_id: &str, +) -> Result { + let raw = connection + .query_row( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND memory_id = ?2", + params![subject_id, memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(raw) = raw { + return parse_memory(&raw); + } + let foreign_owner = connection + .query_row( + "SELECT subject_id FROM memory_records WHERE memory_id = ?1 LIMIT 1", + [memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if foreign_owner.is_some() { + Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: memory_id.to_string(), + }) + } else { + Err(SubjektivError::MemoryNotFound(memory_id.to_string())) + } +} + fn validate_memory_draft(draft: &MemoryDraft) -> Result<()> { validate_nonempty("Memory claim", &draft.claim)?; validate_nonempty("Memory why_useful", &draft.why_useful)?; @@ -2011,6 +2280,7 @@ fn resolution_action_name(action: StagingResolutionAction) -> &'static str { mod tests { use std::sync::{Arc, Barrier}; use std::thread; + use std::time::Duration; use memory::extract::{ExtractedCandidate, StagingEvidence}; use memory::schema::{ @@ -2100,6 +2370,15 @@ mod tests { ) } + fn proposal( + intent: RevisionProposalIntent, + memory_id: &str, + expected_revision: u64, + change_reason: &str, + ) -> RevisionProposal { + RevisionProposal::new(intent, memory_id, expected_revision, change_reason).unwrap() + } + fn open_store( root: &std::path::Path, workspace_id: &str, @@ -2122,8 +2401,52 @@ mod tests { decoded.source_refs[0].session_id.as_deref(), Some("session-1") ); + assert!(decoded.revision_proposal.is_none()); + assert!(!raw.contains("revision_proposal")); assert!(raw.contains("human_input")); + let proposed = staging + .clone() + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + "memory-1", + 3, + "Incorporate the new bounded evidence", + )) + .unwrap(); + let proposed_raw = serde_json::to_string(&proposed).unwrap(); + let proposed_decoded: SubjectStagingRecord = serde_json::from_str(&proposed_raw).unwrap(); + assert_eq!( + proposed_decoded.revision_proposal, + Some(proposal( + RevisionProposalIntent::Revise, + "memory-1", + 3, + "Incorporate the new bounded evidence", + )) + ); + assert!( + RevisionProposal::new( + RevisionProposalIntent::Revise, + "memory-1", + 0, + "invalid zero revision" + ) + .is_err() + ); + assert!( + RevisionProposal::new(RevisionProposalIntent::Revise, "memory-1", 1, " ").is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "intent": "revise", + "memory_id": "memory-1", + "expected_revision": 0, + "change_reason": "invalid" + })) + .is_err() + ); + let mut value = serde_json::to_value(&staging).unwrap(); value .as_object_mut() @@ -2393,6 +2716,330 @@ mod tests { ); } + #[test] + fn revision_proposal_roundtrips_through_staging_and_resolution() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Existing memory", "initial observation")) + .unwrap(); + let proposal = proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Refine the wording from new evidence", + ); + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-proposed", "workspace-a") + .with_revision_proposal(proposal.clone()) + .unwrap(), + ) + .unwrap(); + assert_eq!(staged.revision_proposal, Some(proposal.clone())); + assert_eq!( + store + .staging_candidate(&subject.id, &staged.id) + .unwrap() + .unwrap() + .revision_proposal, + Some(proposal.clone()) + ); + + let resolution = store + .resolve_candidate( + &subject.id, + &staged.id, + StagingResolutionAction::Discarded, + "The current wording remains preferable", + vec![], + ) + .unwrap(); + assert_eq!( + resolution.candidate.revision_proposal, + Some(proposal.clone()) + ); + assert_eq!( + store + .staging_resolution(&subject.id, &staged.id) + .unwrap() + .unwrap() + .candidate + .revision_proposal, + Some(proposal) + ); + } + + #[test] + fn revision_proposals_enforce_scope_revision_and_state_transitions() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory( + &subject.id, + draft("Transition target", "initial observation"), + ) + .unwrap(); + + let foreign = candidate(&other_subject.id, "candidate-foreign-target", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Must remain subject scoped", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(foreign), + Err(SubjektivError::SubjectScopeMismatch { + subject_id, + reference, + }) if subject_id == other_subject.id && reference == memory.id + )); + + let stale = candidate(&subject.id, "candidate-stale", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + 2, + "Resolve the active memory", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(stale), + Err(SubjektivError::RevisionConflict { + memory_id, + expected: 2, + actual: 1, + }) if memory_id == memory.id + )); + + let resolve_proposal = candidate(&subject.id, "candidate-resolve", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + 1, + "The active condition has ended", + )) + .unwrap(); + assert!(store.stage_candidate(resolve_proposal).is_ok()); + let invalid_reopen = candidate(&subject.id, "candidate-invalid-reopen", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + 1, + "An active Memory cannot be reopened", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(invalid_reopen), + Err(SubjektivError::InvalidStateTransition { + from: MemoryState::Active, + to: MemoryState::Active, + .. + }) + )); + + let mut resolve = draft("Resolved target", "the condition ended"); + resolve.state = MemoryState::Resolved; + let resolved = store + .revise_memory(&subject.id, &memory.id, 1, resolve) + .unwrap(); + let mut attributed_stale = + candidate(&subject.id, "candidate-attributed-stale", "workspace-a"); + attributed_stale.source_refs[0].session_id = None; + attributed_stale + .attach_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "This attribution must roll back with the stale proposal", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate_with_attribution( + attributed_stale, + attribution( + &subject.id, + "runtime-proposal", + "worker-proposal", + "session-proposal-stale", + "2026-09-28T12:00:00.000Z", + ), + ), + Err(SubjektivError::RevisionConflict { + expected: 1, + actual: 2, + .. + }) + )); + assert!( + store + .session_attribution("session-proposal-stale") + .unwrap() + .is_none() + ); + let reopen = candidate(&subject.id, "candidate-reopen", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + resolved.revision, + "The condition applies again", + )) + .unwrap(); + assert_eq!( + store + .stage_candidate(reopen) + .unwrap() + .revision_proposal + .unwrap() + .intent, + RevisionProposalIntent::Reopen + ); + let retract_proposal = candidate(&subject.id, "candidate-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Retract, + &memory.id, + resolved.revision, + "The supporting evidence was invalidated", + )) + .unwrap(); + assert!(store.stage_candidate(retract_proposal).is_ok()); + + let mut retract = draft("Retracted target", "the evidence was invalidated"); + retract.state = MemoryState::Retracted; + let retracted = store + .revise_memory(&subject.id, &memory.id, resolved.revision, retract) + .unwrap(); + let terminal = candidate(&subject.id, "candidate-after-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + retracted.revision, + "Retraction must remain terminal", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(terminal), + Err(SubjektivError::InvalidStateTransition { + memory_id, + from: MemoryState::Retracted, + to: MemoryState::Retracted, + }) if memory_id == memory.id + )); + } + + #[test] + fn exact_proposal_retry_remains_idempotent_after_target_advances() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Retry target", "initial observation")) + .unwrap(); + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-retry-proposal", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Refine the current revision", + )) + .unwrap(), + ) + .unwrap(); + store + .revise_memory( + &subject.id, + &memory.id, + 1, + draft("Advanced target", "another accepted refinement"), + ) + .unwrap(); + + let mut retry = staged.clone(); + retry.created_at = "2026-09-28T11:00:00.000Z".into(); + let retried = store.stage_candidate(retry).unwrap(); + assert_eq!(retried.created_at, staged.created_at); + assert_eq!(retried.revision_proposal, staged.revision_proposal); + } + + #[test] + fn bounded_memory_reads_are_scoped_ordered_and_immutable() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + assert!(matches!( + store.list_memories("subject-missing"), + Err(SubjektivError::SubjectNotFound(subject_id)) if subject_id == "subject-missing" + )); + + let mut first_draft = draft("First memory", "first reason"); + first_draft.body_md = "Immutable first body".into(); + let first = store.create_memory(&subject.id, first_draft).unwrap(); + thread::sleep(Duration::from_millis(2)); + let second = store + .create_memory(&subject.id, draft("Second memory", "second reason")) + .unwrap(); + let memories = store.list_memories(&subject.id).unwrap(); + assert_eq!( + memories.iter().map(|record| &record.id).collect::>(), + vec![&second.id, &first.id] + ); + + thread::sleep(Duration::from_millis(2)); + let mut revision = draft("First memory revised", "second revision reason"); + revision.body_md = "New second body".into(); + let revised = store + .revise_memory(&subject.id, &first.id, 1, revision) + .unwrap(); + let memories = store.list_memories(&subject.id).unwrap(); + assert_eq!(memories[0].id, first.id); + assert_eq!(memories[0].revision, 2); + + let history = store.list_memory_revisions(&subject.id, &first.id).unwrap(); + assert_eq!( + history + .iter() + .map(|record| record.revision) + .collect::>(), + vec![2, 1] + ); + assert_eq!(history[0].body_md, "New second body"); + assert_eq!(history[0].change_reason, "second revision reason"); + assert_eq!(history[1].body_md, "Immutable first body"); + assert_eq!(history[1].change_reason, "first reason"); + assert_eq!( + store + .scoped_memory_revision(&subject.id, &first.id, 1) + .unwrap() + .unwrap() + .body_md, + "Immutable first body" + ); + let scoped_current = store.scoped_memory(&subject.id, &first.id).unwrap(); + assert_eq!(scoped_current.id, revised.id); + assert_eq!(scoped_current.revision, revised.revision); + assert_eq!(scoped_current.body_md, revised.body_md); + + assert!(matches!( + store.list_memory_revisions(&other_subject.id, &first.id), + Err(SubjektivError::SubjectScopeMismatch { + subject_id, + reference, + }) if subject_id == other_subject.id && reference == first.id + )); + assert!(matches!( + store.scoped_memory(&other_subject.id, &first.id), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + } + #[test] fn candidate_apply_revision_history_and_resolution_are_atomic() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 50d9c6357..156713fff 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -159,3 +159,64 @@ subsequent consolidation must compare them using the existing candidate identity/content boundary. The system must not claim that the partial writes were rolled back or silently delete them. Subject recall, consolidation, and legacy Memory migration remain separate work. + +## Recall and explicit proposal tools (T-669) + +An enabled `feature.subjektiv` installs five names that do not overlap the legacy +single-Markdown Memory tools: `SubjektivMemoryQuery`, `SubjektivMemoryRead`, +`SubjektivMemoryListRevisions`, `SubjektivMemoryRemember`, and +`SubjektivMemoryProposeRevision`. Runtime-signed Worker source plus current +`subjektiv:` singleton ownership is re-evaluated for every operation; +none of the model-visible inputs contains a subject, Runtime, Worker, Session, +origin, or raw provenance object. + +Query searches only current projections. Omitted `states` means `active`; explicit +empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100, +ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject, +canonical filters, offset, and subject `store_revision`; any confirmed-Memory +change makes it a typed stale-cursor conflict rather than silently mixing +snapshots. Revision history is ordered by revision descending. Its first page +fixes the maximum revision, so later revisions neither duplicate nor displace old +page members. + +Read accepts a positive exact revision or resolves the current revision once. A +missing historical revision never falls back to current. Markdown pagination is +line-based (default 200, maximum 1000) and reports `body_truncated` plus +`body_next_offset`. Provenance uses a separate immutable-revision-bound cursor and +returns at most 20 candidate/derivation references per page. Candidate evidence is +the bounded host-resolved anchor saved in staging; raw Session bodies are not +copied into Memory responses. Resolved, retracted, and historical revisions remain +addressable by ID. + +Remember and ProposeRevision only stage candidates. Entry references are resolved +against the host's committed Session capture, and preference candidates continue +to require exclusively `HumanInput` evidence. When no entry is supplied, a +non-preference request returns `pending_commit`; after the run commits, the host +uses the committed tool-call entry itself as model-origin evidence. An operation +that never commits is not staged. Receipt identity is derived from Session and +tool-call identity, and exact backend retries return the first candidate rather +than creating another one. Neither path changes `memory_records`, +`memory_revisions`, `store_revision`, or a surface snapshot. + +Revision proposals add optional `revision_proposal` metadata to the existing v2 +`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and +its model schema is unchanged. The metadata is serialized atomically with the +candidate and therefore remains present in candidate reads and immutable +resolution copies: + +```json +{ + "intent": "revise", + "memory_id": "memory-…", + "expected_revision": 3, + "change_reason": "The committed evidence corrects the prior condition" +} +``` + +This is the T-670 handoff fixture. Consolidation must re-read this typed metadata, +re-check subject ownership, `expected_revision`, and the requested state +transition in the same transaction that applies the candidate, and leave a stale +proposal unresolved/conflicted. It must not parse target information from the +claim, retarget to the latest revision, or revive a retracted Memory. Valid +transitions at staging are revise while active/resolved, active→resolved, +active/resolved→retracted, and resolved→active. Retraction remains terminal. diff --git a/openapi/server-api.json b/openapi/server-api.json index b0289a493..69ebe5b89 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -14081,7 +14081,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564" + "x-yoi-source-digest": "sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 402ca4ec2..77a81d918 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) +// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index b7729a45a..256443914 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) +// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 44018280e..6bc86ddb3 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) +// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 0e0269569..c6e12177f 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) +// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 03244afc9..b9d390390 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f11b2e87b1d89a62f6885000cd88b0753e4f4743abaa8cb1dbd09c682fcd1564) +// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From f50a0caea918d836a0dbc45857bdc53b1ecfe891 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 09:05:11 +0900 Subject: [PATCH 07/31] fix: harden subject memory delivery --- crates/agen/src/tool.rs | 3 + crates/server-api/src/lib.rs | 27 +- .../src/feature/builtin/subjektiv_memory.rs | 782 ++++++++++++++---- crates/worker/src/session_capture.rs | 57 ++ crates/workspace-server/src/server.rs | 451 +++++++++- docs/design/subjektiv-store.md | 33 +- 6 files changed, 1170 insertions(+), 183 deletions(-) diff --git a/crates/agen/src/tool.rs b/crates/agen/src/tool.rs index 7557d9787..29fbb026a 100644 --- a/crates/agen/src/tool.rs +++ b/crates/agen/src/tool.rs @@ -30,6 +30,9 @@ pub enum ToolError { /// Internal error #[error("Internal error: {0}")] Internal(String), + /// Machine-readable domain conflict that a tool adapter may project as structured output. + #[error("{code}: {message}")] + StructuredConflict { code: String, message: String }, /// Cooperative cancellation completed with bounded terminal output. #[error("Tool execution cancelled")] Cancelled(ToolOutput), diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 2dadb6ef2..bdeb31135 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4619,6 +4619,12 @@ pub struct SubjektivMemoryValidateProposalRequest { pub intent: SubjektivMemoryRevisionIntent, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryReceiptStatusRequest { + pub receipt_id: String, +} + /// Host-resolved staging input. Subject scope and evidence origin are never /// accepted from model-visible tool arguments. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -4646,6 +4652,7 @@ pub enum SubjektivMemoryBackendOperation { Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), ValidateProposal(SubjektivMemoryValidateProposalRequest), + ReceiptStatus(SubjektivMemoryReceiptStatusRequest), StageExplicit(SubjektivMemoryStageExplicitRequest), } @@ -4682,7 +4689,13 @@ pub struct SubjektivMemoryQueryResponse { pub struct SubjektivMemoryEvidenceCandidate { pub candidate_id: String, pub evidence: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub evidence_total: usize, + pub evidence_truncated: bool, pub source_refs: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub source_refs_total: usize, + pub source_refs_truncated: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -4756,10 +4769,21 @@ pub struct SubjektivMemoryProposalValidationResponse { pub intent: SubjektivMemoryRevisionIntent, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjektivMemoryReceiptStatus { + PendingCommit, Staged, + Missing, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryReceiptStatusResponse { + pub receipt_id: String, + pub status: SubjektivMemoryReceiptStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub candidate_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -4781,6 +4805,7 @@ pub enum SubjektivMemoryBackendResponse { Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), ProposalValidated(SubjektivMemoryProposalValidationResponse), + ReceiptStatus(SubjektivMemoryReceiptStatusResponse), Staged(SubjektivMemoryStageExplicitResponse), } diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 20c469752..05b9e0b66 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -4,8 +4,8 @@ //! become immutable staging candidates; T-670 remains the only authority that may //! adopt a candidate as a confirmed revision. -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; +use std::collections::HashSet; +use std::sync::Arc; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use async_trait::async_trait; @@ -15,7 +15,8 @@ use serde::Deserialize; use server_api::{ SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse, SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest, - SubjektivMemoryReceiptStatus, SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal, + SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest, + SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal, SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse, SubjektivMemoryValidateProposalRequest, }; @@ -27,8 +28,9 @@ use crate::feature::{ HookDeclaration, ToolContribution, ToolDeclaration, }; use crate::hook::{ - Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, RunCommitted, - RunCommittedContext, + BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError, + HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, HookPreRequestAction, PreLlmRequest, + PreRequestContext, RunCommitted, RunCommittedContext, }; use crate::session_capture::{SessionCapture, SessionEntryEvidence}; use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; @@ -39,6 +41,9 @@ const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions"; const REMEMBER_TOOL: &str = "SubjektivMemoryRemember"; const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision"; const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit"; +const RETRY_HOOK: &str = "retry-committed-explicit-memory"; +const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite"; +const MAX_EVIDENCE_REFS: usize = 20; const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot."; const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line-bounded Markdown and paged provenance anchors. Continue partial reads using the returned exact revision."; @@ -55,13 +60,11 @@ pub(crate) struct SubjektivMemoryFeature { struct SubjektivMemoryState { client: Arc, capture: CommittedSessionCaptureHandle, - pending: Arc>>, } #[derive(Clone)] struct PendingExplicit { receipt_id: String, - call_id: String, kind: CandidateKind, claim: String, why_useful: String, @@ -88,11 +91,7 @@ impl SubjektivMemoryFeature { )); } Ok(Some(Self { - state: SubjektivMemoryState { - client, - capture, - pending: Arc::new(Mutex::new(HashMap::new())), - }, + state: SubjektivMemoryState { client, capture }, })) } } @@ -116,6 +115,14 @@ impl FeatureModule for SubjektivMemoryFeature { COMMIT_HOOK, FeatureHookPoint::RunCommitted, )) + .with_hook(HookDeclaration::new( + RETRY_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + .with_hook(HookDeclaration::new( + REWRITE_HOOK, + FeatureHookPoint::BeforeSessionRewrite, + )) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { @@ -157,6 +164,19 @@ impl FeatureModule for SubjektivMemoryFeature { PendingCommitHook { state: self.state.clone(), }, + )?; + context.hooks().add_pre_request( + RETRY_HOOK, + PendingRetryHook { + state: self.state.clone(), + }, + )?; + context.hooks().add_before_session_rewrite( + REWRITE_HOOK, + HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 30_000), + PendingRewriteHook { + state: self.state.clone(), + }, ) } } @@ -217,7 +237,10 @@ impl Tool for SubjektivReadTool { LIST_REVISIONS_TOOL, )?), }; - let response = self.state.execute(operation)?; + let response = match self.state.execute(operation) { + Ok(response) => response, + Err(error) => return structured_conflict_output(error), + }; let (summary, value) = match response { SubjektivMemoryBackendResponse::Query(value) => ( format!("Found {} Memory item(s).", value.items.len()), @@ -346,14 +369,18 @@ impl Tool for SubjektivExplicitTool { )); } let validation = - self.state + match self + .state .execute(SubjektivMemoryBackendOperation::ValidateProposal( SubjektivMemoryValidateProposalRequest { memory_id: params.memory_id.clone(), expected_revision: params.expected_revision, intent: params.intent, }, - ))?; + )) { + Ok(response) => response, + Err(error) => return structured_conflict_output(error), + }; let validated = match validation { SubjektivMemoryBackendResponse::ProposalValidated(value) => value, other => { @@ -379,6 +406,11 @@ impl Tool for SubjektivExplicitTool { } }; validate_candidate_text(&claim, &why_useful)?; + if entry_refs.len() > MAX_EVIDENCE_REFS { + return Err(ToolError::InvalidArgument(format!( + "entry_refs is limited to {MAX_EVIDENCE_REFS} committed references" + ))); + } if proposal .as_ref() .is_some_and(|proposal| proposal.change_reason.trim().is_empty()) @@ -395,28 +427,11 @@ impl Tool for SubjektivExplicitTool { .into(), )); } - let pending = PendingExplicit { - receipt_id: receipt_id.clone(), - call_id, - kind, - claim, - why_useful, - staleness, - proposal: proposal.clone(), - }; - let mut queue = self.state.pending.lock().map_err(|_| { - ToolError::ExecutionFailed("subjektiv pending queue poisoned".into()) - })?; - match queue.get(&receipt_id) { - Some(existing) if !pending_equivalent(existing, &pending) => { - return Err(ToolError::InvalidArgument( - "this tool operation was retried with different content".into(), - )); - } - Some(_) => {} - None => { - queue.insert(receipt_id.clone(), pending); - } + let status = self.state.receipt_status(&receipt_id)?; + if status.status == SubjektivMemoryReceiptStatus::Staged { + let value = serde_json::to_value(status) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + return json_output("Explicit Memory candidate is already staged.", &value); } let value = serde_json::json!({ "receipt_id": receipt_id, @@ -436,7 +451,7 @@ impl Tool for SubjektivExplicitTool { let entries = resolve_entry_refs(&capture, &entry_refs)?; ensure_preference_evidence(&kind, &entries)?; - let response = self.state.stage( + let response = match self.state.stage( &receipt_id, &capture.session_id, kind, @@ -445,7 +460,10 @@ impl Tool for SubjektivExplicitTool { staleness, entries, proposal, - )?; + ) { + Ok(response) => response, + Err(error) => return structured_conflict_output(error), + }; staged_output(response) } } @@ -462,9 +480,31 @@ impl SubjektivMemoryState { )) .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; if !response.is_success() { + let parsed = + serde_json::from_str::(&response.body).ok(); + if let Some((code, message)) = parsed.as_ref().and_then(|error| { + error + .diagnostics + .iter() + .find(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "revision_conflict" | "stale_cursor" + ) + }) + .map(|diagnostic| (diagnostic.code.as_str(), error.message.as_str())) + }) { + return Err(ToolError::StructuredConflict { + code: code.to_string(), + message: message.to_string(), + }); + } + let detail = parsed + .map(|error| error.message) + .unwrap_or_else(|| response.body.clone()); let message = format!( - "subjektiv Memory backend returned HTTP {}: {}", - response.status, response.body + "subjektiv Memory backend returned HTTP {}: {detail}", + response.status ); return if matches!(response.status, 400 | 404 | 409 | 422) { Err(ToolError::InvalidArgument(message)) @@ -477,6 +517,48 @@ impl SubjektivMemoryState { }) } + fn backend_receipt_status( + &self, + expected_receipt_id: &str, + ) -> Result { + match self.execute(SubjektivMemoryBackendOperation::ReceiptStatus( + SubjektivMemoryReceiptStatusRequest { + receipt_id: expected_receipt_id.to_string(), + }, + ))? { + SubjektivMemoryBackendResponse::ReceiptStatus(value) => Ok(value), + other => Err(ToolError::ExecutionFailed(format!( + "unexpected receipt status response: {other:?}" + ))), + } + } + + fn receipt_status( + &self, + expected_receipt_id: &str, + ) -> Result { + let mut status = self.backend_receipt_status(expected_receipt_id)?; + if status.status == SubjektivMemoryReceiptStatus::Missing { + let capture = self + .capture + .capture() + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + let session_id = capture.session_id.clone(); + let view = SessionCapture::from_history_entries(capture.segment_id, capture.history); + let committed = view + .committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) + .into_iter() + .any(|call| { + receipt_id(&session_id, &call.call_id) + .is_ok_and(|committed_receipt| committed_receipt == expected_receipt_id) + }); + if committed { + status.status = SubjektivMemoryReceiptStatus::PendingCommit; + } + } + Ok(status) + } + #[allow(clippy::too_many_arguments)] fn stage( &self, @@ -525,63 +607,164 @@ struct PendingCommitHook { #[async_trait] impl Hook for PendingCommitHook { async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> { - let capture = - self.state.capture.capture().map_err(|error| { - HookError::new(HookErrorCategory::Dependency, error.to_string()) - })?; - let view = SessionCapture::from_history_entries( - capture.segment_id.clone(), - capture.history.clone(), - ); - let pending = self - .state - .pending - .lock() - .map_err(|_| HookError::new(HookErrorCategory::Internal, "pending queue poisoned"))? - .values() - .cloned() - .collect::>(); - let mut completed = Vec::new(); - let mut failures = Vec::new(); - for item in pending { - let Some(evidence) = view.evidence_for_tool_call(&item.call_id) else { - // The operation belongs to an uncommitted or different Session. + replay_pending_committed_memory(&self.state) + } +} + +struct PendingRetryHook { + state: SubjektivMemoryState, +} + +#[async_trait] +impl Hook for PendingRetryHook { + async fn call(&self, _input: &PreRequestContext) -> Result { + replay_pending_committed_memory(&self.state)?; + Ok(HookPreRequestAction::Continue) + } +} + +struct PendingRewriteHook { + state: SubjektivMemoryState, +} + +#[async_trait] +impl Hook for PendingRewriteHook { + async fn call( + &self, + _input: &BeforeSessionRewriteContext, + ) -> Result { + replay_pending_committed_memory(&self.state)?; + Ok(BeforeSessionRewriteAction::Continue) + } +} + +fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), HookError> { + let capture = state + .capture + .capture() + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + let view = + SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone()); + let mut failures = Vec::new(); + for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) { + let receipt_id = match receipt_id(&capture.session_id, &call.call_id) { + Ok(value) => value, + Err(error) => { + failures.push(error.to_string()); continue; + } + }; + match state.backend_receipt_status(&receipt_id) { + Ok(status) if status.status == SubjektivMemoryReceiptStatus::Staged => continue, + Ok(_) => {} + Err(error) => { + failures.push(format!("{receipt_id}: {error}")); + continue; + } + } + let item = + match pending_from_committed_call(state, &receipt_id, &call.name, &call.arguments) { + Ok(Some(value)) => value, + Ok(None) => continue, + Err(error) => { + failures.push(format!("{receipt_id}: {error}")); + continue; + } }; - match self.state.stage( - &item.receipt_id, - &capture.session_id, - item.kind.clone(), - item.claim, - item.why_useful, - item.staleness, - vec![evidence], - item.proposal, - ) { - Ok(_) => completed.push(item.receipt_id), - Err(error) => failures.push(error.to_string()), - } - } - if !completed.is_empty() { - let mut queue = self.state.pending.lock().map_err(|_| { - HookError::new(HookErrorCategory::Internal, "pending queue poisoned") - })?; - for receipt_id in completed { - queue.remove(&receipt_id); - } - } - if failures.is_empty() { - Ok(()) - } else { - Err(HookError::new( - HookErrorCategory::Dependency, - format!( - "explicit Memory staging after commit failed: {}", - failures.join("; ") - ), - )) + if let Err(error) = state.stage( + &item.receipt_id, + &capture.session_id, + item.kind, + item.claim, + item.why_useful, + item.staleness, + vec![call.evidence], + item.proposal, + ) { + failures.push(format!("{receipt_id}: {error}")); } } + if failures.is_empty() { + Ok(()) + } else { + Err(HookError::new( + HookErrorCategory::Dependency, + format!( + "explicit Memory staging after commit failed: {}", + failures.join("; ") + ), + )) + } +} + +fn pending_from_committed_call( + state: &SubjektivMemoryState, + receipt_id: &str, + tool_name: &str, + arguments: &str, +) -> Result, ToolError> { + let (kind, claim, why_useful, staleness, entry_refs, proposal) = match tool_name { + REMEMBER_TOOL => { + let params: RememberParams = parse(arguments, REMEMBER_TOOL)?; + ( + params.kind, + params.claim, + params.why_useful, + params.staleness, + params.entry_refs, + None, + ) + } + PROPOSE_REVISION_TOOL => { + let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?; + let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal( + SubjektivMemoryValidateProposalRequest { + memory_id: params.memory_id.clone(), + expected_revision: params.expected_revision, + intent: params.intent, + }, + ))?; + let validated = match response { + SubjektivMemoryBackendResponse::ProposalValidated(value) => value, + other => { + return Err(ToolError::ExecutionFailed(format!( + "unexpected proposal validation response: {other:?}" + ))); + } + }; + ( + validated.kind, + params.claim, + params.why_useful, + params.staleness, + params.entry_refs, + Some(SubjektivMemoryRevisionProposal { + memory_id: params.memory_id, + expected_revision: params.expected_revision, + intent: params.intent, + change_reason: params.change_reason, + }), + ) + } + _ => return Ok(None), + }; + if !entry_refs.is_empty() { + return Ok(None); + } + validate_candidate_text(&claim, &why_useful)?; + if kind == CandidateKind::Preference { + return Err(ToolError::InvalidArgument( + "preference candidates cannot use model-origin implicit evidence".into(), + )); + } + Ok(Some(PendingExplicit { + receipt_id: receipt_id.to_string(), + kind, + claim, + why_useful, + staleness, + proposal, + })) } fn resolve_entry_refs( @@ -636,16 +819,6 @@ fn receipt_id(session_id: &str, call_id: &str) -> Result { Ok(value) } -fn pending_equivalent(left: &PendingExplicit, right: &PendingExplicit) -> bool { - left.receipt_id == right.receipt_id - && left.call_id == right.call_id - && left.kind == right.kind - && left.claim == right.claim - && left.why_useful == right.why_useful - && left.staleness == right.staleness - && left.proposal == right.proposal -} - fn validate_candidate_text(claim: &str, why_useful: &str) -> Result<(), ToolError> { required(claim, "claim")?; required(why_useful, "why_useful")?; @@ -669,6 +842,22 @@ fn schema_for() -> serde_json::Value { serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({})) } +fn structured_conflict_output(error: ToolError) -> Result { + match error { + ToolError::StructuredConflict { code, message } => json_output( + format!("Subjektiv Memory operation rejected with {code}."), + &serde_json::json!({ + "status": "error", + "error": { + "code": code, + "message": message, + }, + }), + ), + other => Err(other), + } +} + fn staged_output(response: SubjektivMemoryStageExplicitResponse) -> Result { if !matches!(response.status, SubjektivMemoryReceiptStatus::Staged) { return Err(ToolError::ExecutionFailed( @@ -705,6 +894,9 @@ mod tests { use super::*; use crate::feature::session::{CommittedRunExit, CommittedSessionCapture}; use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + use std::collections::HashSet; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Debug)] struct TestWorkspaceClient; @@ -722,19 +914,73 @@ mod tests { true } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let request: SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + let SubjektivMemoryBackendOperation::ReceiptStatus(input) = request.operation else { + return Err(WorkspaceClientError::Request( + "test client only supports receipt lookup".into(), + )); + }; + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&SubjektivMemoryBackendResponse::ReceiptStatus( + server_api::SubjektivMemoryReceiptStatusResponse { + receipt_id: input.receipt_id, + status: SubjektivMemoryReceiptStatus::Missing, + candidate_id: None, + }, + )) + .unwrap(), + }) + } + } + + #[derive(Debug)] + struct ConflictWorkspaceClient(&'static str); + + impl WorkspaceClient for ConflictWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "conflict-test" + } + + fn is_available(&self) -> bool { + true + } + fn execute( &self, _request: WorkspaceRequest, ) -> Result { - Err(WorkspaceClientError::Request( - "test client has no backend".into(), - )) + Ok(WorkspaceResponse { + status: 409, + body: serde_json::to_string(&server_api::RepositoryApiError::new( + 409, + "Conflict", + format!("{}: retry with a fresh snapshot", self.0), + vec![server_api::Diagnostic { + code: self.0.to_string(), + severity: server_api::DiagnosticSeverity::Error, + message: "typed conflict".into(), + }], + )) + .unwrap(), + }) } } #[derive(Debug, Default)] struct RecordingWorkspaceClient { requests: Mutex>, + staged: Mutex>, + fail_stages: AtomicUsize, } impl WorkspaceClient for RecordingWorkspaceClient { @@ -754,19 +1000,52 @@ mod tests { &self, request: WorkspaceRequest, ) -> Result { - self.requests.lock().unwrap().push(request); - Ok(WorkspaceResponse { - status: 200, - body: serde_json::to_string(&SubjektivMemoryBackendResponse::Staged( - SubjektivMemoryStageExplicitResponse { - candidate_id: "explicit:session-1:call-commit".into(), - receipt_id: "explicit:session-1:call-commit".into(), + self.requests.lock().unwrap().push(request.clone()); + let request: SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + let response = match request.operation { + SubjektivMemoryBackendOperation::ReceiptStatus(input) => { + let staged = self.staged.lock().unwrap().contains(&input.receipt_id); + SubjektivMemoryBackendResponse::ReceiptStatus( + server_api::SubjektivMemoryReceiptStatusResponse { + receipt_id: input.receipt_id.clone(), + status: if staged { + SubjektivMemoryReceiptStatus::Staged + } else { + SubjektivMemoryReceiptStatus::Missing + }, + candidate_id: staged.then_some(input.receipt_id), + }, + ) + } + SubjektivMemoryBackendOperation::StageExplicit(input) => { + if self + .fail_stages + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Ok(WorkspaceResponse { + status: 503, + body: r#"{"error":"Unavailable","message":"retry","diagnostics":[]}"# + .into(), + }); + } + self.staged.lock().unwrap().insert(input.receipt_id.clone()); + SubjektivMemoryBackendResponse::Staged(SubjektivMemoryStageExplicitResponse { + candidate_id: input.receipt_id.clone(), + receipt_id: input.receipt_id, status: SubjektivMemoryReceiptStatus::Staged, target: None, intent: None, - }, - )) - .unwrap(), + }) + } + other => panic!("unexpected test operation: {other:?}"), + }; + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&response).unwrap(), }) } } @@ -788,7 +1067,6 @@ mod tests { state: SubjektivMemoryState { client: Arc::new(TestWorkspaceClient), capture, - pending: Arc::new(Mutex::new(HashMap::new())), }, } } @@ -811,9 +1089,16 @@ mod tests { PROPOSE_REVISION_TOOL, ] ); - assert_eq!(descriptor.hooks.len(), 1); + assert_eq!(descriptor.hooks.len(), 3); assert_eq!(descriptor.hooks[0].name, COMMIT_HOOK); assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::RunCommitted); + assert_eq!(descriptor.hooks[1].name, RETRY_HOOK); + assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest); + assert_eq!(descriptor.hooks[2].name, REWRITE_HOOK); + assert_eq!( + descriptor.hooks[2].point, + FeatureHookPoint::BeforeSessionRewrite + ); } #[test] @@ -845,6 +1130,27 @@ mod tests { assert!(parsed.entry_refs.is_empty()); } + #[tokio::test] + async fn backend_conflict_discriminants_survive_as_structured_tool_output() { + for code in ["revision_conflict", "stale_cursor"] { + let tool = SubjektivReadTool { + state: SubjektivMemoryState { + client: Arc::new(ConflictWorkspaceClient(code)), + capture: feature().state.capture, + }, + operation: ReadOperation::Query, + }; + let output = tool + .execute("{}", ToolExecutionContext::new("call", "batch", 0)) + .await + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(value["status"], "error"); + assert_eq!(value["error"]["code"], code); + } + } + #[test] fn receipt_identity_is_stable_and_rejects_invalid_values() { assert_eq!( @@ -872,12 +1178,9 @@ mod tests { serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); assert_eq!(value["status"], "pending_commit"); assert_eq!(value["receipt_id"], "explicit:session-1:call-1"); - assert_eq!(feature.state.pending.lock().unwrap().len(), 1); - tool.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) .await .unwrap(); - assert_eq!(feature.state.pending.lock().unwrap().len(), 1); let preference = tool .execute( @@ -890,25 +1193,173 @@ mod tests { } #[tokio::test] - async fn committed_tool_call_stages_pending_candidate_with_host_evidence() { + async fn cancelled_or_uncommitted_calls_are_not_replayed() { + let client = Arc::new(RecordingWorkspaceClient::default()); + let capture = CommittedSessionCaptureHandle::new(|| { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: 1, + entry_count: 3, + run_exit: CommittedRunExit::Interrupted, + history: vec![ + crate::session_history::history_entry( + agen::Item::tool_call( + "call-uncommitted", + REMEMBER_TOOL, + r#"{"kind":"lesson","claim":"No result","why_useful":"Never stage"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ModelOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + crate::session_history::history_entry( + agen::Item::tool_call( + "call-cancelled", + REMEMBER_TOOL, + r#"{"kind":"lesson","claim":"Cancelled","why_useful":"Never stage"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ModelOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + crate::session_history::history_entry( + agen::Item::tool_result_item("call-cancelled", "cancelled", None, true), + crate::session_history::WorkerHistoryProvenance::ToolOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + ], + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }); + PendingCommitHook { + state: SubjektivMemoryState { + client: client.clone(), + capture, + }, + } + .call(&RunCommittedContext { + invocation: crate::hook::HookInvocationContext::default(), + exit: crate::hook::RunCommittedExit::Interrupted, + committed_history: crate::hook::HookHistoryRange::default(), + }) + .await + .unwrap(); + assert!(client.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn failed_commit_hook_retries_from_durable_history() { + let client = Arc::new(RecordingWorkspaceClient::default()); + client.fail_stages.store(1, Ordering::SeqCst); + let capture = CommittedSessionCaptureHandle::new(|| { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: 2, + entry_count: 2, + run_exit: CommittedRunExit::Finished, + history: vec![ + crate::session_history::history_entry( + agen::Item::tool_call( + "call-retry", + REMEMBER_TOOL, + r#"{"kind":"lesson","claim":"Retry","why_useful":"Recover after failure"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ModelOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + crate::session_history::history_entry( + agen::Item::tool_result_with_content( + "call-retry", + "pending commit", + r#"{"status":"pending_commit"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ToolOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + ], + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }); + let hook = PendingCommitHook { + state: SubjektivMemoryState { + client: client.clone(), + capture, + }, + }; + let context = RunCommittedContext { + invocation: crate::hook::HookInvocationContext::default(), + exit: crate::hook::RunCommittedExit::Finished, + committed_history: crate::hook::HookHistoryRange::default(), + }; + assert!(hook.call(&context).await.is_err()); + hook.call(&context).await.unwrap(); + hook.call(&context).await.unwrap(); + assert!( + client + .staged + .lock() + .unwrap() + .contains("explicit:session-1:call-retry") + ); + let stage_attempts = client + .requests + .lock() + .unwrap() + .iter() + .filter(|request| { + let request: SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + matches!( + request.operation, + SubjektivMemoryBackendOperation::StageExplicit(_) + ) + }) + .count(); + assert_eq!( + stage_attempts, 2, + "a staged receipt must not be replayed again" + ); + } + + #[tokio::test] + async fn committed_tool_call_is_replayed_after_restart_with_host_evidence() { let client = Arc::new(RecordingWorkspaceClient::default()); let capture = CommittedSessionCaptureHandle::new(|| { Ok(CommittedSessionCapture { session_id: "session-1".into(), segment_id: "segment-1".into(), session_revision: 2, - entry_count: 1, + entry_count: 2, run_exit: CommittedRunExit::Finished, - history: vec![crate::session_history::history_entry( - agen::Item::tool_call( - "call-commit", - REMEMBER_TOOL, - r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#, + history: vec![ + crate::session_history::history_entry( + agen::Item::tool_call( + "call-commit", + REMEMBER_TOOL, + r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ModelOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, ), - crate::session_history::WorkerHistoryProvenance::ModelOutput { - worker: crate::session_history::worker_subject(Default::default()), - }, - )], + crate::session_history::history_entry( + agen::Item::tool_result_with_content( + "call-commit", + "pending commit", + r#"{"status":"pending_commit"}"#, + ), + crate::session_history::WorkerHistoryProvenance::ToolOutput { + worker: crate::session_history::worker_subject(Default::default()), + }, + ), + ], usage_history: Vec::new(), extensions: Vec::new(), }) @@ -916,47 +1367,52 @@ mod tests { let state = SubjektivMemoryState { client: client.clone(), capture, - pending: Arc::new(Mutex::new(HashMap::new())), }; - let tool = SubjektivExplicitTool { - state: state.clone(), - operation: ExplicitOperation::Remember, - }; - tool.execute( - r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#, - ToolExecutionContext::new("call-commit", "batch-1", 0), - ) - .await - .unwrap(); - assert_eq!(state.pending.lock().unwrap().len(), 1); + assert_eq!( + state + .receipt_status("explicit:session-1:call-commit") + .unwrap() + .status, + SubjektivMemoryReceiptStatus::PendingCommit + ); - PendingCommitHook { + PendingRetryHook { state: state.clone(), } - .call(&RunCommittedContext { - invocation: crate::hook::HookInvocationContext::default(), - exit: crate::hook::RunCommittedExit::Finished, - committed_history: crate::hook::HookHistoryRange::default(), - }) + .call(&crate::hook::PreRequestContext::new( + crate::hook::PreRequestInfo { + item_count: 2, + estimated_tokens: None, + turn_index: 0, + tool_calls_this_turn: 0, + }, + None, + )) .await .unwrap(); - assert!(state.pending.lock().unwrap().is_empty()); let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - let body = requests[0].body.as_deref().unwrap(); - let request: SubjektivMemoryBackendRequest = serde_json::from_str(body).unwrap(); - let SubjektivMemoryBackendOperation::StageExplicit(staged) = request.operation else { - panic!("expected explicit stage request"); - }; - assert_eq!(staged.session_id, "session-1"); - assert_eq!(staged.evidence.len(), 1); + let request = requests + .iter() + .filter_map(|request| { + serde_json::from_str::( + request.body.as_deref().unwrap(), + ) + .ok() + }) + .find_map(|request| match request.operation { + SubjektivMemoryBackendOperation::StageExplicit(staged) => Some(staged), + _ => None, + }) + .expect("restart replay must stage the committed receipt"); + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.evidence.len(), 1); assert_eq!( - staged.evidence[0].origin.as_ref().unwrap().kind, + request.evidence[0].origin.as_ref().unwrap().kind, memory::schema::EvidenceOriginKind::ModelOutput ); assert_eq!( - staged.source_refs[0].evidence_id.as_deref(), - Some(staged.evidence[0].id.as_str()) + request.source_refs[0].evidence_id.as_deref(), + Some(request.evidence[0].id.as_str()) ); } } diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index 86c9fb911..ad7a25adf 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -4,6 +4,7 @@ //! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded //! range/search, read, and generic evidence projections without granting mutation authority. +use std::collections::HashSet; use std::sync::Arc; use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance}; @@ -207,6 +208,14 @@ pub(crate) struct ReadResult { pub truncated: bool, } +#[derive(Debug, Clone)] +pub(crate) struct CommittedToolCall { + pub call_id: String, + pub name: String, + pub arguments: String, + pub evidence: SessionEntryEvidence, +} + #[derive(Debug, Clone)] pub(crate) struct SessionEntryEvidence { pub segment_id: String, @@ -622,6 +631,54 @@ impl SessionCapture { self.evidence_for(reference.id.as_str()) } + pub(crate) fn committed_pending_tool_calls(&self, names: &[&str]) -> Vec { + let pending_call_ids = self + .entries + .iter() + .filter_map(|entry| { + let Item::ToolResult { + call_id, + content: Some(content), + is_error: false, + .. + } = &entry.item + else { + return None; + }; + (serde_json::from_str::(content) + .ok() + .and_then(|value| value.get("status").cloned()) + .as_ref() + .and_then(serde_json::Value::as_str) + == Some("pending_commit")) + .then_some(call_id.as_str()) + }) + .collect::>(); + self.entries + .iter() + .filter_map(|entry| { + let Item::ToolCall { + call_id, + name, + arguments, + .. + } = &entry.item + else { + return None; + }; + if !names.contains(&name.as_str()) || !pending_call_ids.contains(call_id.as_str()) { + return None; + } + Some(CommittedToolCall { + call_id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + evidence: self.evidence_for_tool_call(call_id)?, + }) + }) + .collect() + } + pub(crate) fn evidence_for(&self, id: &str) -> Option { let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?; let excerpt = self diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ec8d813ab..fdb939ee3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -16776,6 +16776,9 @@ const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20; +const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = 20; +const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64; +const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 32 * 1024; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -16840,6 +16843,23 @@ async fn scoped_subjektiv_memory_backend( subjektiv_memory_validate_proposal(&store, &subject_id, input)?, ) } + server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => { + validate_subjektiv_receipt_id(&input.receipt_id)?; + let candidate = store + .staging_candidate(&subject_id, &input.receipt_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ReceiptStatus( + server_api::SubjektivMemoryReceiptStatusResponse { + receipt_id: input.receipt_id, + status: if candidate.is_some() { + server_api::SubjektivMemoryReceiptStatus::Staged + } else { + server_api::SubjektivMemoryReceiptStatus::Missing + }, + candidate_id: candidate.map(|record| record.id), + }, + ) + } server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => { // Re-derive the same subject and immutable Session attribution for the // write. The request never carries subject, Worker, Runtime, origin, @@ -16891,7 +16911,7 @@ fn subjektiv_memory_query( } if cursor.store_revision != subject.store_revision { return Err(Error::RepositoryConflict( - "subjektiv query cursor is stale because Memory changed; restart the query".into(), + "stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), ) .into()); } @@ -16983,6 +17003,12 @@ fn subjektiv_memory_read( .into()); } let body_offset = input.offset.unwrap_or(0); + if body_offset > 0 && input.revision.is_none() { + return Err(Error::InvalidInput( + "body continuation requires the exact revision returned by the first read".into(), + ) + .into()); + } let lines = memory_body_lines(&record.body_md); if body_offset > lines.len() { return Err(Error::InvalidInput("body line offset exceeds document length".into()).into()); @@ -17021,6 +17047,8 @@ fn subjektiv_memory_read( let mut source_candidate_ids = Vec::new(); let mut source_candidates = Vec::new(); let mut derived_from = Vec::new(); + let mut evidence_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT; + let mut source_ref_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT; for index in evidence_offset..evidence_end { if let Some(candidate_id) = record.source_candidate_ids.get(index) { let candidate = store @@ -17032,10 +17060,30 @@ fn subjektiv_memory_read( )) })?; source_candidate_ids.push(candidate_id.clone()); + let evidence_total = candidate.evidence.len(); + let source_refs_total = candidate.source_refs.len(); + let evidence_take = evidence_total.min(evidence_budget); + let source_refs_take = source_refs_total.min(source_ref_budget); + evidence_budget -= evidence_take; + source_ref_budget -= source_refs_take; source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { candidate_id: candidate.id, - evidence: candidate.evidence, - source_refs: candidate.source_refs, + evidence: candidate + .evidence + .into_iter() + .take(evidence_take) + .map(bounded_subjektiv_staging_evidence) + .collect(), + evidence_total, + evidence_truncated: evidence_total > evidence_take, + source_refs: candidate + .source_refs + .into_iter() + .take(source_refs_take) + .map(bounded_subjektiv_source_ref) + .collect(), + source_refs_total, + source_refs_truncated: source_refs_total > source_refs_take, }); } else { let derived_index = index - record.source_candidate_ids.len(); @@ -17046,6 +17094,7 @@ fn subjektiv_memory_read( }); } } + bound_subjektiv_nested_evidence(&mut source_candidates)?; let evidence_has_more = evidence_end < total_evidence; let evidence_next_cursor = evidence_has_more .then(|| { @@ -17180,21 +17229,35 @@ fn subjektiv_memory_validate_proposal( }) } +fn validate_subjektiv_receipt_id(receipt_id: &str) -> ApiResult<()> { + let receipt_id = receipt_id.trim(); + if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) { + return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into()); + } + Ok(()) +} + fn subjektiv_memory_stage_explicit( store: &crate::subjektiv::SubjektivStore, mut input: server_api::SubjektivMemoryStageExplicitRequest, attribution: crate::subjektiv::SubjectSessionAttribution, ) -> ApiResult { + validate_subjektiv_receipt_id(&input.receipt_id)?; let receipt_id = input.receipt_id.trim(); - if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) { - return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into()); - } if input.evidence.is_empty() || input.source_refs.is_empty() { return Err(Error::InvalidInput( "explicit Memory staging requires host-resolved committed evidence".into(), ) .into()); } + if input.evidence.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + || input.source_refs.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + { + return Err(Error::InvalidInput(format!( + "explicit Memory evidence/source_refs are limited to {SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT} items each" + )) + .into()); + } if matches!(input.kind, memory::extract::CandidateKind::Preference) && input.evidence.iter().any(|evidence| { !matches!( @@ -17454,6 +17517,103 @@ fn memory_body_lines(body: &str) -> Vec<&str> { } } +fn bound_subjektiv_nested_evidence( + candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate], +) -> ApiResult<()> { + loop { + let serialized = serde_json::to_vec(candidates) + .map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?; + if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES { + return Ok(()); + } + let mut removed = false; + for candidate in candidates.iter_mut().rev() { + if candidate.source_refs.pop().is_some() { + candidate.source_refs_truncated = true; + removed = true; + break; + } + if candidate.evidence.pop().is_some() { + candidate.evidence_truncated = true; + removed = true; + break; + } + } + if !removed { + return Err(Error::Store( + "subjektiv evidence metadata exceeds its response byte budget".into(), + ) + .into()); + } + } +} + +fn bounded_subjektiv_staging_evidence( + mut evidence: memory::extract::StagingEvidence, +) -> memory::extract::StagingEvidence { + evidence.kind = memory::schema::EvidenceKind::new(bounded_utf8_bytes( + evidence.kind.as_str().to_string(), + SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, + )); + evidence.origin = evidence.origin.map(bounded_subjektiv_evidence_origin); + evidence.excerpt = evidence + .excerpt + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + evidence.summary = evidence + .summary + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + evidence +} + +fn bounded_subjektiv_source_ref( + mut source: memory::schema::SourceEvidenceRef, +) -> memory::schema::SourceEvidenceRef { + source.origin = source.origin.map(bounded_subjektiv_evidence_origin); + source.evidence_kind = source.evidence_kind.map(|kind| { + memory::schema::EvidenceKind::new(bounded_utf8_bytes( + kind.as_str().to_string(), + SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, + )) + }); + source.label = source + .label + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + source.summary = source + .summary + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + source +} + +fn bounded_subjektiv_evidence_origin( + mut origin: memory::schema::EvidenceOrigin, +) -> memory::schema::EvidenceOrigin { + for field in [ + &mut origin.account_id, + &mut origin.workspace_id, + &mut origin.runtime_id, + &mut origin.worker_id, + &mut origin.flow_selector, + &mut origin.flow_definition_id, + ] { + if let Some(value) = field.take() { + *field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + } + } + origin +} + +fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value; + } + let marker = "…"; + let mut end = max_bytes.saturating_sub(marker.len()); + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + format!("{}{marker}", &value[..end]) +} + fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String { let normalized = body.split_whitespace().collect::>().join(" "); let mut chars = normalized.chars(); @@ -28772,12 +28932,27 @@ impl From for ApiError { severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(&ticket_error.to_string()), }], + Error::RepositoryConflict(message) => subjektiv_conflict_code(message) + .map(|code| { + vec![RuntimeDiagnostic { + code: code.to_string(), + severity: HostDiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }] + }) + .unwrap_or_default(), _ => Vec::new(), }; Self { error, diagnostics } } } +fn subjektiv_conflict_code(message: &str) -> Option<&'static str> { + ["revision_conflict", "stale_cursor"] + .into_iter() + .find(|code| message.starts_with(code)) +} + impl ApiError { fn with_diagnostics(error: Error, diagnostics: Vec) -> Self { Self { error, diagnostics } @@ -34968,12 +35143,46 @@ mod tests { excerpt: Some("Correct the first rule".to_string()), summary: Some("Human correction".to_string()), }; + let oversized = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::StageExplicit( + server_api::SubjektivMemoryStageExplicitRequest { + receipt_id: "explicit:session-1:oversized".to_string(), + session_id: "session-1".to_string(), + kind: memory::extract::CandidateKind::Decision, + claim: "Too many anchors".to_string(), + why_useful: "Prove response bounds".to_string(), + staleness: None, + evidence: vec![ + explicit_evidence.clone(); + SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 1 + ], + source_refs: vec![memory::schema::SourceEvidenceRef { + segment_id: Some("segment-1".to_string()), + entry_range: Some([0, 0]), + evidence_id: Some("E00000000".to_string()), + ..Default::default() + }], + proposal: None, + }, + ), + }), + ) + .await + .unwrap_err(); + assert!(matches!(oversized.error, Error::InvalidInput(_))); + let Json(explicit) = scoped_subjektiv_memory_backend( State(api.clone()), AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), }), - context, + context.clone(), Json(server_api::SubjektivMemoryBackendRequest { operation: server_api::SubjektivMemoryBackendOperation::StageExplicit( server_api::SubjektivMemoryStageExplicitRequest { @@ -35007,6 +35216,34 @@ mod tests { other => panic!("unexpected explicit response: {other:?}"), }; assert_eq!(staged.candidate_id, "explicit:session-1:call-1"); + let Json(status) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context, + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ReceiptStatus( + server_api::SubjektivMemoryReceiptStatusRequest { + receipt_id: staged.receipt_id.clone(), + }, + ), + }), + ) + .await + .unwrap(); + let status = match status { + server_api::SubjektivMemoryBackendResponse::ReceiptStatus(status) => status, + other => panic!("unexpected receipt response: {other:?}"), + }; + assert_eq!( + status.status, + server_api::SubjektivMemoryReceiptStatus::Staged + ); + assert_eq!( + status.candidate_id.as_deref(), + Some(staged.candidate_id.as_str()) + ); let candidate = store .staging_candidate(&subject.id, &staged.candidate_id) .unwrap() @@ -35067,6 +35304,18 @@ mod tests { store .revise_memory(&subject.id, &second.id, 1, resolved) .unwrap(); + let revision_conflict = subjektiv_memory_validate_proposal( + &store, + &subject.id, + server_api::SubjektivMemoryValidateProposalRequest { + memory_id: second.id.clone(), + expected_revision: 1, + intent: server_api::SubjektivMemoryRevisionIntent::Revise, + }, + ) + .unwrap_err(); + let revision_api = ApiError::from(revision_conflict.error).into_repository_api_error(); + assert_eq!(revision_api.diagnostics[0].code, "revision_conflict"); let default_page = subjektiv_memory_query( &store, @@ -35142,7 +35391,51 @@ mod tests { }, ) .unwrap_err(); - assert!(matches!(stale.error, Error::RepositoryConflict(_))); + assert!(matches!(&stale.error, Error::RepositoryConflict(_))); + let stale_api = ApiError::from(stale.error).into_repository_api_error(); + assert_eq!(stale_api.diagnostics[0].code, "stale_cursor"); + + let initial_read = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: first.id.clone(), + revision: None, + offset: None, + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap(); + assert_eq!(initial_read.revision, 1); + assert_eq!(initial_read.body_next_offset, Some(1)); + store + .revise_memory( + &subject.id, + &first.id, + 1, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Choose stable cursors", + "replacement one\nreplacement two\n", + "Prevents mixed snapshots", + "replace current body", + ), + ) + .unwrap(); + let unbound_continuation = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: first.id.clone(), + revision: None, + offset: initial_read.body_next_offset, + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap_err(); + assert!(matches!(unbound_continuation.error, Error::InvalidInput(_))); let read = subjektiv_memory_read( &store, @@ -35160,7 +35453,147 @@ mod tests { assert_eq!(read.body_next_offset, Some(2)); assert!(read.body_truncated); assert_eq!(read.revision, 1); - assert_eq!(read.current_revision, 1); + assert_eq!(read.current_revision, 2); + + let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 5; + let nested_evidence = (0..nested_count) + .map(|index| memory::extract::StagingEvidence { + id: format!("nested-{index}-{}", "i".repeat(220)), + kind: memory::schema::EvidenceKind::new(format!( + "custom-{index}-{}", + "k".repeat(4_000) + )), + entry_range: Some([index as u64, index as u64]), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::ModelOutput, + account_id: Some(format!("account-{index}-{}", "a".repeat(4_000))), + workspace_id: None, + runtime_id: Some(format!("runtime-{index}-{}", "r".repeat(4_000))), + worker_id: Some(format!("worker-{index}-{}", "w".repeat(4_000))), + flow_selector: Some(format!("flow-{index}-{}", "f".repeat(4_000))), + flow_definition_id: Some(format!("definition-{index}-{}", "d".repeat(4_000))), + flow_definition_revision: None, + }), + excerpt: Some(format!("evidence-{index}-{}", "x".repeat(4_000))), + summary: Some(format!("evidence-summary-{index}-{}", "q".repeat(4_000))), + }) + .collect::>(); + let nested_refs = (0..nested_count) + .map(|index| memory::schema::SourceEvidenceRef { + session_id: Some(format!("session-{index}-{}", "u".repeat(220))), + segment_id: Some(format!("segment-{index}-{}", "g".repeat(220))), + entry_range: Some([index as u64, index as u64]), + evidence_id: Some(format!("nested-{index}-{}", "i".repeat(220))), + evidence_kind: Some(memory::schema::EvidenceKind::new(format!( + "source-custom-{index}-{}", + "s".repeat(4_000) + ))), + label: Some(format!("source-{index}-{}", "y".repeat(4_000))), + summary: Some(format!("summary-{index}-{}", "z".repeat(4_000))), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::ModelOutput, + account_id: Some(format!("source-account-{index}-{}", "a".repeat(4_000))), + workspace_id: None, + runtime_id: Some(format!("source-runtime-{index}-{}", "r".repeat(4_000))), + worker_id: Some(format!("source-worker-{index}-{}", "w".repeat(4_000))), + flow_selector: Some(format!("source-flow-{index}-{}", "f".repeat(4_000))), + flow_definition_id: Some(format!( + "source-definition-{index}-{}", + "d".repeat(4_000) + )), + flow_definition_revision: Some(index as u64), + }), + ..Default::default() + }) + .collect::>(); + let nested_candidate = memory::extract::StagingRecord::from_candidate( + "candidate-nested", + "nested-dedup", + memory::schema::SourceRef { + segment_id: "segment-nested".to_string(), + range: [0, nested_count as u64 - 1], + }, + memory::extract::ExtractedCandidate { + kind: memory::extract::CandidateKind::Lesson, + claim: "Keep nested provenance bounded".to_string(), + why_useful: "Prevents unbounded read responses".to_string(), + staleness: None, + evidence_ids: nested_evidence.iter().map(|item| item.id.clone()).collect(), + }, + nested_evidence, + nested_refs, + ); + let nested_candidate = store + .stage_candidate(crate::subjektiv::SubjectStagingRecord::attach( + &subject.id, + nested_candidate, + )) + .unwrap(); + let (nested_memory, _) = store + .apply_candidate( + &subject.id, + &nested_candidate.id, + crate::subjektiv::MemoryRevisionTarget::Create, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep nested provenance bounded", + "bounded body", + "Prevents unbounded read responses", + "adopt oversized historical candidate", + ), + "adopt for response-bound regression", + ) + .unwrap(); + let nested_read = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: nested_memory.id, + revision: Some(1), + offset: None, + limit: None, + evidence_cursor: None, + }, + ) + .unwrap(); + assert_eq!(nested_read.source_candidates.len(), 1); + let nested = &nested_read.source_candidates[0]; + assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); + assert_eq!(nested.evidence_total, nested_count); + assert!(nested.evidence_truncated); + assert!(!nested.source_refs.is_empty()); + assert!(nested.source_refs.len() <= SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); + assert_eq!(nested.source_refs_total, nested_count); + assert!(nested.source_refs_truncated); + assert!(nested.evidence.iter().all(|item| { + item.excerpt + .as_ref() + .is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES) + })); + assert!(nested.evidence.iter().all(|item| { + item.kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES + && item + .origin + .as_ref() + .and_then(|origin| origin.worker_id.as_ref()) + .is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES) + })); + assert!(nested.source_refs.iter().all(|item| { + item.label + .as_ref() + .is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES) + && item + .evidence_kind + .as_ref() + .is_none_or(|kind| kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES) + })); + assert!( + serde_json::to_vec(&nested_read.source_candidates) + .unwrap() + .len() + <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES + ); + assert!(serde_json::to_vec(&nested_read).unwrap().len() < 64 * 1024); let revisions = subjektiv_memory_list_revisions( &store, diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 156713fff..04726b1bc 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -175,28 +175,41 @@ empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100 ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject, canonical filters, offset, and subject `store_revision`; any confirmed-Memory change makes it a typed stale-cursor conflict rather than silently mixing -snapshots. Revision history is ordered by revision descending. Its first page +snapshots. Revision conflicts and stale cursors retain their diagnostic code as a +structured `{status: "error", error: {code, message}}` tool result instead of +requiring models to parse prose. Revision history is ordered by revision +descending. Its first page fixes the maximum revision, so later revisions neither duplicate nor displace old page members. Read accepts a positive exact revision or resolves the current revision once. A missing historical revision never falls back to current. Markdown pagination is line-based (default 200, maximum 1000) and reports `body_truncated` plus -`body_next_offset`. Provenance uses a separate immutable-revision-bound cursor and -returns at most 20 candidate/derivation references per page. Candidate evidence is -the bounded host-resolved anchor saved in staging; raw Session bodies are not -copied into Memory responses. Resolved, retracted, and historical revisions remain +`body_next_offset`; every nonzero continuation offset must also supply the exact +revision returned by the first page, so a current-revision change cannot mix body +versions. Provenance uses a separate immutable-revision-bound cursor and returns at +most 20 candidate/derivation references per page. Expanded candidates share one +page-wide budget of 20 evidence records and 20 source references, plus a hard 32 KiB +serialized budget for the expanded candidate array; displayed anchor text is capped +at 64 UTF-8 bytes and total/truncation metadata reports omissions. +Explicit staging rejects larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied +into Memory responses. Resolved, retracted, and historical revisions remain addressable by ID. Remember and ProposeRevision only stage candidates. Entry references are resolved against the host's committed Session capture, and preference candidates continue to require exclusively `HumanInput` evidence. When no entry is supplied, a non-preference request returns `pending_commit`; after the run commits, the host -uses the committed tool-call entry itself as model-origin evidence. An operation -that never commits is not staged. Receipt identity is derived from Session and -tool-call identity, and exact backend retries return the first candidate rather -than creating another one. Neither path changes `memory_records`, -`memory_revisions`, `store_revision`, or a surface snapshot. +reconstructs pending receipts from the durable tool-call/tool-result history and +uses the committed tool-call entry itself as model-origin evidence. Receipt lookup +reports `pending_commit`, `staged`, or `missing`; the post-commit hook retries +immediately, the pre-request hook replays durable pending receipts after Worker +restore, and the fail-closed pre-rewrite hook prevents compaction from dropping an +unstaged receipt. Hook failure and process restart therefore retain an idempotent +retry path, while an operation that never commits is not staged. Receipt identity is derived from Session and tool-call identity, +and exact backend retries return the first candidate rather than creating another +one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`, +or a surface snapshot. Revision proposals add optional `revision_proposal` metadata to the existing v2 `SubjectStagingRecord` envelope; automatic extraction remains proposal-free and From 860c34b8da6306a26f5022784d4bc967a3bf6c0b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 09:26:53 +0900 Subject: [PATCH 08/31] fix: bound memory read continuations --- crates/server-api/src/lib.rs | 10 + .../src/feature/builtin/subjektiv_memory.rs | 4 +- crates/workspace-server/src/server.rs | 220 +++++++++++++++--- crates/workspace-server/src/subjektiv.rs | 7 + docs/design/subjektiv-store.md | 17 +- 5 files changed, 211 insertions(+), 47 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index bdeb31135..e1ba60188 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4592,6 +4592,11 @@ pub struct SubjektivMemoryReadRequest { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: Option, + /// UTF-8 byte offset within `offset`'s line. Used only for continuation of + /// one line that exceeds the response byte budget. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub byte_offset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = 1000))] pub limit: Option, @@ -4719,9 +4724,14 @@ pub struct SubjektivMemoryReadResponse { pub updated_at: String, #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub body_offset: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub body_byte_offset: usize, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub body_next_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub body_next_byte_offset: Option, pub body_truncated: bool, /// Candidate ids on this evidence page. The corresponding bounded anchors /// are returned in `source_candidates` with the same order. diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 05b9e0b66..ba2c351d5 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -43,10 +43,10 @@ const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision"; const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit"; const RETRY_HOOK: &str = "retry-committed-explicit-memory"; const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite"; -const MAX_EVIDENCE_REFS: usize = 20; +const MAX_EVIDENCE_REFS: usize = 10; const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot."; -const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line-bounded Markdown and paged provenance anchors. Continue partial reads using the returned exact revision."; +const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line- and byte-bounded Markdown plus paged provenance anchors. Continue partial reads using the returned exact revision, line offset, and byte offset."; const LIST_REVISIONS_DESCRIPTION: &str = "List immutable revisions of one connected-subject Memory in descending revision order using snapshot-bound pagination."; const REMEMBER_DESCRIPTION: &str = "Explicitly stage a new Memory candidate for the connected subject. This never writes confirmed Memory. With no entry_refs, the committed tool call itself becomes model-origin evidence after the run commits and status is pending_commit."; const PROPOSE_DESCRIPTION: &str = "Stage a typed correction, resolution, retraction, or reopen proposal for an exact current Memory revision. This validates optimistic concurrency now but never creates a confirmed revision."; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fdb939ee3..fca165a4b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -16775,10 +16775,11 @@ const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; -const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20; -const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = 20; +const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024; +const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 1; +const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::subjektiv::MAX_STAGING_ANCHORS; const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64; -const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 32 * 1024; +const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024; #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -17003,7 +17004,8 @@ fn subjektiv_memory_read( .into()); } let body_offset = input.offset.unwrap_or(0); - if body_offset > 0 && input.revision.is_none() { + let body_byte_offset = input.byte_offset.unwrap_or(0); + if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() { return Err(Error::InvalidInput( "body continuation requires the exact revision returned by the first read".into(), ) @@ -17013,9 +17015,28 @@ fn subjektiv_memory_read( if body_offset > lines.len() { return Err(Error::InvalidInput("body line offset exceeds document length".into()).into()); } - let body_end = body_offset.saturating_add(body_limit).min(lines.len()); - let body_truncated = body_end < lines.len(); - let body_md = lines[body_offset..body_end].concat(); + if body_offset == lines.len() && body_byte_offset > 0 { + return Err(Error::InvalidInput("body byte offset exceeds document length".into()).into()); + } + if let Some(line) = lines.get(body_offset) + && (body_byte_offset > line.len() || !line.is_char_boundary(body_byte_offset)) + { + return Err(Error::InvalidInput( + "body byte offset must be a UTF-8 boundary within the selected line".into(), + ) + .into()); + } + let (body_md, body_next) = bounded_memory_body_page( + &lines, + body_offset, + body_byte_offset, + body_limit, + SUBJEKTIV_BODY_MAX_BYTES, + ); + let body_truncated = body_next.is_some(); + let (body_next_offset, body_next_byte_offset) = body_next + .map(|(line, byte)| (Some(line), Some(byte))) + .unwrap_or((None, None)); let evidence_offset = if let Some(cursor) = input.evidence_cursor { let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; @@ -17124,7 +17145,9 @@ fn subjektiv_memory_read( created_at: record.created_at, updated_at: record.updated_at, body_offset, - body_next_offset: body_truncated.then_some(body_end), + body_byte_offset, + body_next_offset, + body_next_byte_offset, body_truncated, source_candidate_ids, source_candidates, @@ -17509,6 +17532,45 @@ fn api_proposal_intent( } } +fn bounded_memory_body_page( + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, + byte_limit: usize, +) -> (String, Option<(usize, usize)>) { + let line_end = line_offset.saturating_add(line_limit).min(lines.len()); + let mut line_index = line_offset; + let mut within_line = byte_offset; + let mut body = String::new(); + while line_index < line_end { + let line = lines[line_index]; + let remainder = &line[within_line..]; + let available = byte_limit.saturating_sub(body.len()); + if remainder.len() <= available { + body.push_str(remainder); + line_index += 1; + within_line = 0; + continue; + } + let mut take = available.min(remainder.len()); + while take > 0 && !remainder.is_char_boundary(take) { + take -= 1; + } + if take == 0 { + return (body, Some((line_index, within_line))); + } + body.push_str(&remainder[..take]); + within_line += take; + return (body, Some((line_index, within_line))); + } + if line_index < lines.len() { + (body, Some((line_index, 0))) + } else { + (body, None) + } +} + fn memory_body_lines(body: &str) -> Vec<&str> { if body.is_empty() { Vec::new() @@ -17520,31 +17582,15 @@ fn memory_body_lines(body: &str) -> Vec<&str> { fn bound_subjektiv_nested_evidence( candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate], ) -> ApiResult<()> { - loop { - let serialized = serde_json::to_vec(candidates) - .map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?; - if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES { - return Ok(()); - } - let mut removed = false; - for candidate in candidates.iter_mut().rev() { - if candidate.source_refs.pop().is_some() { - candidate.source_refs_truncated = true; - removed = true; - break; - } - if candidate.evidence.pop().is_some() { - candidate.evidence_truncated = true; - removed = true; - break; - } - } - if !removed { - return Err(Error::Store( - "subjektiv evidence metadata exceeds its response byte budget".into(), - ) - .into()); - } + let serialized = serde_json::to_vec(candidates) + .map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?; + if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES { + Ok(()) + } else { + Err(Error::Store( + "subjektiv evidence page exceeds its enforced response byte budget".into(), + ) + .into()) } } @@ -17603,6 +17649,16 @@ fn bounded_subjektiv_evidence_origin( } fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String { + let value = value + .chars() + .map(|character| { + if character.is_control() || matches!(character, '"' | '\\') { + ' ' + } else { + character + } + }) + .collect::(); if value.len() <= max_bytes { return value; } @@ -35402,6 +35458,7 @@ mod tests { memory_id: first.id.clone(), revision: None, offset: None, + byte_offset: None, limit: Some(1), evidence_cursor: None, }, @@ -35430,6 +35487,7 @@ mod tests { memory_id: first.id.clone(), revision: None, offset: initial_read.body_next_offset, + byte_offset: initial_read.body_next_byte_offset, limit: Some(1), evidence_cursor: None, }, @@ -35444,6 +35502,7 @@ mod tests { memory_id: first.id.clone(), revision: Some(1), offset: Some(1), + byte_offset: None, limit: Some(1), evidence_cursor: None, }, @@ -35455,7 +35514,55 @@ mod tests { assert_eq!(read.revision, 1); assert_eq!(read.current_revision, 2); - let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 5; + let oversized_body = "界".repeat(7_000); + let oversized_memory = store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Page oversized lines by byte", + oversized_body.clone(), + "Keeps tool JSON intact", + "oversized line regression", + ), + ) + .unwrap(); + let oversized_first = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: oversized_memory.id.clone(), + revision: None, + offset: None, + byte_offset: None, + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap(); + assert!(oversized_first.body_md.len() <= SUBJEKTIV_BODY_MAX_BYTES); + assert!(oversized_first.body_truncated); + assert_eq!(oversized_first.body_next_offset, Some(0)); + let oversized_second = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: oversized_memory.id, + revision: Some(oversized_first.revision), + offset: oversized_first.body_next_offset, + byte_offset: oversized_first.body_next_byte_offset, + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap(); + assert!(!oversized_second.body_truncated); + assert_eq!( + format!("{}{}", oversized_first.body_md, oversized_second.body_md), + oversized_body + ); + + let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT; let nested_evidence = (0..nested_count) .map(|index| memory::extract::StagingEvidence { id: format!("nested-{index}-{}", "i".repeat(220)), @@ -35506,6 +35613,43 @@ mod tests { ..Default::default() }) .collect::>(); + let mut rejected_evidence = nested_evidence.clone(); + let mut extra_evidence = rejected_evidence[0].clone(); + extra_evidence.id = "nested-over-limit".to_string(); + rejected_evidence.push(extra_evidence); + let mut rejected_refs = nested_refs.clone(); + let mut extra_ref = rejected_refs[0].clone(); + extra_ref.evidence_id = Some("nested-over-limit".to_string()); + rejected_refs.push(extra_ref); + let rejected_candidate = memory::extract::StagingRecord::from_candidate( + "candidate-over-anchor-limit", + "over-anchor-limit-dedup", + memory::schema::SourceRef { + segment_id: "segment-nested".to_string(), + range: [0, nested_count as u64], + }, + memory::extract::ExtractedCandidate { + kind: memory::extract::CandidateKind::Lesson, + claim: "Reject unpageable provenance".to_string(), + why_useful: "Every staging path shares the read bound".to_string(), + staleness: None, + evidence_ids: rejected_evidence + .iter() + .map(|item| item.id.clone()) + .collect(), + }, + rejected_evidence, + rejected_refs, + ); + let rejected = store.stage_candidate(crate::subjektiv::SubjectStagingRecord::attach( + &subject.id, + rejected_candidate, + )); + assert!(matches!( + rejected, + Err(crate::subjektiv::SubjektivError::InvalidRecord(message)) + if message.contains("limited to") + )); let nested_candidate = memory::extract::StagingRecord::from_candidate( "candidate-nested", "nested-dedup", @@ -35551,6 +35695,7 @@ mod tests { memory_id: nested_memory.id, revision: Some(1), offset: None, + byte_offset: None, limit: None, evidence_cursor: None, }, @@ -35560,11 +35705,10 @@ mod tests { let nested = &nested_read.source_candidates[0]; assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); assert_eq!(nested.evidence_total, nested_count); - assert!(nested.evidence_truncated); - assert!(!nested.source_refs.is_empty()); - assert!(nested.source_refs.len() <= SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); + assert!(!nested.evidence_truncated); + assert_eq!(nested.source_refs.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); assert_eq!(nested.source_refs_total, nested_count); - assert!(nested.source_refs_truncated); + assert!(!nested.source_refs_truncated); assert!(nested.evidence.iter().all(|item| { item.excerpt .as_ref() diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 17bf71d3c..b27aac270 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -22,6 +22,7 @@ use crate::feature_storage::{ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; +pub const MAX_STAGING_ANCHORS: usize = 10; pub type Result = std::result::Result; @@ -1898,6 +1899,12 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> )); } + if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS + { + return Err(SubjektivError::InvalidRecord(format!( + "staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each" + ))); + } let mut evidence_ids = HashSet::new(); for evidence in &record.evidence { validate_label("evidence id", &evidence.id)?; diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 04726b1bc..264b93d23 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -184,15 +184,18 @@ page members. Read accepts a positive exact revision or resolves the current revision once. A missing historical revision never falls back to current. Markdown pagination is -line-based (default 200, maximum 1000) and reports `body_truncated` plus -`body_next_offset`; every nonzero continuation offset must also supply the exact +line-based (default 200, maximum 1000) with an additional 16 KiB UTF-8 response +budget. It reports `body_truncated`, `body_next_offset`, and +`body_next_byte_offset`; an oversized single line resumes from the returned byte +boundary within that same line. Every continuation offset must also supply the exact revision returned by the first page, so a current-revision change cannot mix body -versions. Provenance uses a separate immutable-revision-bound cursor and returns at -most 20 candidate/derivation references per page. Expanded candidates share one -page-wide budget of 20 evidence records and 20 source references, plus a hard 32 KiB +versions. Provenance uses a separate immutable-revision-bound cursor and returns one +candidate/derivation reference per page. A staged candidate contains at most 10 +evidence records and 10 source references, all returned together under a hard 40 KiB serialized budget for the expanded candidate array; displayed anchor text is capped -at 64 UTF-8 bytes and total/truncation metadata reports omissions. -Explicit staging rejects larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied +at 64 UTF-8 bytes. Every staging path rejects larger anchor sets, so accepted +provenance is never made unreachable. Candidate evidence is the bounded +host-resolved anchor saved in staging; raw Session bodies are not copied into Memory responses. Resolved, retracted, and historical revisions remain addressable by ID. From 8f8c2676b4db4d6c0b75111c0af84444cff9a2c7 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 09:52:32 +0900 Subject: [PATCH 09/31] fix: keep memory reads globally bounded --- crates/server-api/src/lib.rs | 11 +- .../src/feature/builtin/subjektiv_memory.rs | 152 +++++- crates/workspace-server/src/server.rs | 464 +++++++++++++++--- crates/workspace-server/src/subjektiv.rs | 36 +- docs/design/subjektiv-store.md | 26 +- 5 files changed, 593 insertions(+), 96 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e1ba60188..d3aea23ba 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4689,6 +4689,11 @@ pub struct SubjektivMemoryQueryResponse { pub has_more: bool, } +/// Maximum pretty-serialized model-visible content for one subject Memory read. +/// This stays below the Worker's generic 64 KiB tool-content ceiling so the +/// result remains valid JSON with explicit continuation coordinates. +pub const SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivMemoryEvidenceCandidate { @@ -4733,8 +4738,10 @@ pub struct SubjektivMemoryReadResponse { #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub body_next_byte_offset: Option, pub body_truncated: bool, - /// Candidate ids on this evidence page. The corresponding bounded anchors - /// are returned in `source_candidates` with the same order. + /// Candidate ids on this evidence page. A compatible historical candidate + /// may repeat across pages while its nested anchors advance through the same + /// evidence cursor. Corresponding bounded anchors retain the same order in + /// `source_candidates`. pub source_candidate_ids: Vec, pub source_candidates: Vec, /// Derivation references on this evidence page. diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index ba2c351d5..9a901e1e7 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -268,7 +268,17 @@ impl Tool for SubjektivReadTool { } }; let value = value.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - Ok(json_output(summary, &value)?) + let output = json_output(summary, &value)?; + if matches!(self.operation, ReadOperation::Read) + && output.content.as_ref().is_some_and(|content| { + content.len() > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + }) + { + return Err(ToolError::ExecutionFailed( + "subjektiv Memory read exceeded its model-visible output budget".into(), + )); + } + Ok(output) } } @@ -976,6 +986,40 @@ mod tests { } } + #[derive(Debug)] + struct ReadWorkspaceClient(server_api::SubjektivMemoryReadResponse); + + impl WorkspaceClient for ReadWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "read-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let request: SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert!(matches!( + request.operation, + SubjektivMemoryBackendOperation::Read(_) + )); + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&SubjektivMemoryBackendResponse::Read(self.0.clone())) + .unwrap(), + }) + } + } + #[derive(Debug, Default)] struct RecordingWorkspaceClient { requests: Mutex>, @@ -1130,6 +1174,112 @@ mod tests { assert!(parsed.entry_refs.is_empty()); } + #[tokio::test] + async fn read_tool_preserves_bounded_model_visible_json_with_escape_heavy_content() { + let origin = || memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::ModelOutput, + account_id: Some("a".repeat(64)), + workspace_id: Some("workspace-test".into()), + runtime_id: Some("r".repeat(64)), + worker_id: Some("w".repeat(64)), + flow_selector: Some("f".repeat(64)), + flow_definition_id: Some("d".repeat(64)), + flow_definition_revision: Some(1), + }; + let evidence = (0..MAX_EVIDENCE_REFS) + .map(|index| memory::extract::StagingEvidence { + id: format!("evidence-{index}"), + kind: memory::schema::EvidenceKind::new("model_output"), + entry_range: Some([index as u64, index as u64]), + origin: Some(origin()), + excerpt: Some("\\\u{0000}".repeat(32)), + summary: Some("\\\u{0001}".repeat(32)), + }) + .collect::>(); + let source_refs = (0..MAX_EVIDENCE_REFS) + .map(|index| memory::schema::SourceEvidenceRef { + session_id: Some(format!("session-{index}")), + segment_id: Some(format!("segment-{index}")), + entry_range: Some([index as u64, index as u64]), + evidence_id: Some(format!("evidence-{index}")), + origin: Some(origin()), + evidence_kind: Some(memory::schema::EvidenceKind::new("model_output")), + label: Some("\\\u{0002}".repeat(32)), + summary: Some("\\\u{0003}".repeat(32)), + }) + .collect::>(); + let response = server_api::SubjektivMemoryReadResponse { + memory_id: "memory-1".into(), + revision: 1, + current_revision: 1, + kind: CandidateKind::Lesson, + state: server_api::SubjektivMemoryState::Active, + claim: "Escape-aware read".into(), + body_md: "\\\u{0000}\u{0001}".repeat(2_000), + why_useful: "Proves the actual ToolOutput remains parseable".into(), + staleness: None, + change_reason: "test".into(), + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + body_offset: 0, + body_byte_offset: 0, + body_next_offset: Some(0), + body_next_byte_offset: Some(6_000), + body_truncated: true, + source_candidate_ids: vec!["candidate-1".into()], + source_candidates: vec![server_api::SubjektivMemoryEvidenceCandidate { + candidate_id: "candidate-1".into(), + evidence, + evidence_total: MAX_EVIDENCE_REFS, + evidence_truncated: false, + source_refs, + source_refs_total: MAX_EVIDENCE_REFS, + source_refs_truncated: false, + }], + derived_from: Vec::new(), + evidence_next_cursor: None, + evidence_has_more: false, + }; + let mut oversized_response = response.clone(); + oversized_response.body_md = + "\\\u{0000}".repeat(server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES); + let tool = SubjektivReadTool { + state: SubjektivMemoryState { + client: Arc::new(ReadWorkspaceClient(response)), + capture: feature().state.capture, + }, + operation: ReadOperation::Read, + }; + let output = tool + .execute( + r#"{"memory_id":"memory-1"}"#, + ToolExecutionContext::new("call-read", "batch-read", 0), + ) + .await + .unwrap(); + let content = output.content.unwrap(); + assert!(content.len() <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(value["body_truncated"], true); + assert_eq!(value["body_next_byte_offset"], 6_000); + + let oversized_tool = SubjektivReadTool { + state: SubjektivMemoryState { + client: Arc::new(ReadWorkspaceClient(oversized_response)), + capture: feature().state.capture, + }, + operation: ReadOperation::Read, + }; + let error = oversized_tool + .execute( + r#"{"memory_id":"memory-1"}"#, + ToolExecutionContext::new("call-oversized", "batch-read", 1), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("model-visible output budget")); + } + #[tokio::test] async fn backend_conflict_discriminants_survive_as_structured_tool_output() { for code in ["revision_conflict", "stale_cursor"] { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fca165a4b..ba610e53c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -16776,8 +16776,8 @@ const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024; -const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 1; const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::subjektiv::MAX_STAGING_ANCHORS; +const SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE: usize = 2; const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64; const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024; @@ -16807,7 +16807,14 @@ struct SubjektivEvidenceCursor { subject_id: String, memory_id: String, revision: u64, + /// Candidate/derivation reference offset within the immutable revision. offset: usize, + /// Nested offsets keep compatible historical candidates with more anchors + /// resumable without admitting new over-limit records. + #[serde(default)] + evidence_offset: usize, + #[serde(default)] + source_ref_offset: usize, } async fn scoped_subjektiv_memory_backend( @@ -17038,21 +17045,26 @@ fn subjektiv_memory_read( .map(|(line, byte)| (Some(line), Some(byte))) .unwrap_or((None, None)); - let evidence_offset = if let Some(cursor) = input.evidence_cursor { - let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; - if cursor.subject_id != subject_id - || cursor.memory_id != record.id - || cursor.revision != record.revision - { - return Err(Error::InvalidInput( - "subjektiv evidence cursor does not match subject, Memory, or revision".into(), + let (evidence_offset, nested_evidence_offset, nested_source_ref_offset) = + if let Some(cursor) = input.evidence_cursor { + let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; + if cursor.subject_id != subject_id + || cursor.memory_id != record.id + || cursor.revision != record.revision + { + return Err(Error::InvalidInput( + "subjektiv evidence cursor does not match subject, Memory, or revision".into(), + ) + .into()); + } + ( + cursor.offset, + cursor.evidence_offset, + cursor.source_ref_offset, ) - .into()); - } - cursor.offset - } else { - 0 - }; + } else { + (0, 0, 0) + }; let total_evidence = record .source_candidate_ids .len() @@ -17062,61 +17074,89 @@ fn subjektiv_memory_read( Error::InvalidInput("subjektiv evidence cursor offset is invalid".into()).into(), ); } - let evidence_end = evidence_offset - .saturating_add(SUBJEKTIV_EVIDENCE_PAGE_SIZE) - .min(total_evidence); + let mut source_candidate_ids = Vec::new(); let mut source_candidates = Vec::new(); let mut derived_from = Vec::new(); - let mut evidence_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT; - let mut source_ref_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT; - for index in evidence_offset..evidence_end { - if let Some(candidate_id) = record.source_candidate_ids.get(index) { - let candidate = store - .staging_candidate(subject_id, candidate_id) - .map_err(subjektiv_store_error)? - .ok_or_else(|| { - Error::Store(format!( - "subjektiv candidate provenance `{candidate_id}` is missing" - )) - })?; - source_candidate_ids.push(candidate_id.clone()); - let evidence_total = candidate.evidence.len(); - let source_refs_total = candidate.source_refs.len(); - let evidence_take = evidence_total.min(evidence_budget); - let source_refs_take = source_refs_total.min(source_ref_budget); - evidence_budget -= evidence_take; - source_ref_budget -= source_refs_take; - source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { - candidate_id: candidate.id, - evidence: candidate - .evidence - .into_iter() - .take(evidence_take) - .map(bounded_subjektiv_staging_evidence) - .collect(), - evidence_total, - evidence_truncated: evidence_total > evidence_take, - source_refs: candidate - .source_refs - .into_iter() - .take(source_refs_take) - .map(bounded_subjektiv_source_ref) - .collect(), - source_refs_total, - source_refs_truncated: source_refs_total > source_refs_take, - }); - } else { - let derived_index = index - record.source_candidate_ids.len(); - let reference = &record.derived_from[derived_index]; - derived_from.push(server_api::SubjektivMemoryRevisionRef { - memory_id: reference.memory_id.clone(), - revision: reference.revision, - }); + let mut next_evidence_offset = evidence_offset; + let mut next_nested_evidence_offset = 0; + let mut next_nested_source_ref_offset = 0; + if let Some(candidate_id) = record.source_candidate_ids.get(evidence_offset) { + let candidate = store + .staging_candidate(subject_id, candidate_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + Error::Store(format!( + "subjektiv candidate provenance `{candidate_id}` is missing" + )) + })?; + let evidence_total = candidate.evidence.len(); + let source_refs_total = candidate.source_refs.len(); + if nested_evidence_offset > evidence_total || nested_source_ref_offset > source_refs_total { + return Err(Error::InvalidInput( + "subjektiv nested evidence cursor offset is invalid".into(), + ) + .into()); } + let evidence_end = nested_evidence_offset + .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) + .min(evidence_total); + let source_ref_end = nested_source_ref_offset + .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) + .min(source_refs_total); + source_candidate_ids.push(candidate_id.clone()); + source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { + candidate_id: candidate.id, + evidence: candidate + .evidence + .into_iter() + .skip(nested_evidence_offset) + .take(evidence_end - nested_evidence_offset) + .map(bounded_subjektiv_staging_evidence) + .collect(), + evidence_total, + evidence_truncated: nested_evidence_offset > 0 || evidence_end < evidence_total, + source_refs: candidate + .source_refs + .into_iter() + .skip(nested_source_ref_offset) + .take(source_ref_end - nested_source_ref_offset) + .map(bounded_subjektiv_source_ref) + .collect(), + source_refs_total, + source_refs_truncated: nested_source_ref_offset > 0 + || source_ref_end < source_refs_total, + }); + if evidence_end < evidence_total || source_ref_end < source_refs_total { + next_nested_evidence_offset = evidence_end; + next_nested_source_ref_offset = source_ref_end; + } else { + next_evidence_offset += 1; + } + } else if evidence_offset < total_evidence { + if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { + return Err(Error::InvalidInput( + "subjektiv derivation cursor cannot contain nested offsets".into(), + ) + .into()); + } + let derived_index = evidence_offset - record.source_candidate_ids.len(); + let reference = &record.derived_from[derived_index]; + derived_from.push(server_api::SubjektivMemoryRevisionRef { + memory_id: reference.memory_id.clone(), + revision: reference.revision, + }); + next_evidence_offset += 1; + } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { + return Err(Error::InvalidInput( + "subjektiv evidence cursor points beyond the final reference".into(), + ) + .into()); } - bound_subjektiv_nested_evidence(&mut source_candidates)?; - let evidence_has_more = evidence_end < total_evidence; + bound_subjektiv_nested_evidence(&source_candidates)?; + let evidence_has_more = next_evidence_offset < total_evidence + || next_nested_evidence_offset != 0 + || next_nested_source_ref_offset != 0; let evidence_next_cursor = evidence_has_more .then(|| { encode_subjektiv_cursor( @@ -17125,13 +17165,15 @@ fn subjektiv_memory_read( subject_id: subject_id.to_string(), memory_id: record.id.clone(), revision: record.revision, - offset: evidence_end, + offset: next_evidence_offset, + evidence_offset: next_nested_evidence_offset, + source_ref_offset: next_nested_source_ref_offset, }, ) }) .transpose()?; - Ok(server_api::SubjektivMemoryReadResponse { + let mut response = server_api::SubjektivMemoryReadResponse { memory_id: record.id, revision: record.revision, current_revision: current.revision, @@ -17154,7 +17196,15 @@ fn subjektiv_memory_read( derived_from, evidence_next_cursor, evidence_has_more, - }) + }; + fit_subjektiv_read_body_to_output_budget( + &mut response, + &lines, + body_offset, + body_byte_offset, + body_limit, + )?; + Ok(response) } fn subjektiv_memory_list_revisions( @@ -17532,6 +17582,76 @@ fn api_proposal_intent( } } +fn fit_subjektiv_read_body_to_output_budget( + response: &mut server_api::SubjektivMemoryReadResponse, + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, +) -> ApiResult<()> { + let serialized_len = |response: &server_api::SubjektivMemoryReadResponse| { + serde_json::to_vec_pretty(response) + .map(|value| value.len()) + .map_err(|error| Error::Store(format!("encode subjektiv Memory read: {error}"))) + }; + if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + return Ok(()); + } + + let initial_body_len = response.body_md.len(); + let mut lower = 0usize; + let mut upper = initial_body_len; + while lower < upper { + let candidate_limit = lower + (upper - lower).div_ceil(2); + set_subjektiv_read_body_page( + response, + lines, + line_offset, + byte_offset, + line_limit, + candidate_limit, + ); + if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + lower = candidate_limit; + } else { + upper = candidate_limit - 1; + } + } + set_subjektiv_read_body_page(response, lines, line_offset, byte_offset, line_limit, lower); + let final_len = serialized_len(response)?; + if final_len > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + return Err(Error::Store(format!( + "subjektiv Memory metadata/provenance requires {final_len} bytes and exceeds the {} byte read-output budget", + server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + )) + .into()); + } + if initial_body_len > 0 && response.body_md.is_empty() { + return Err(Error::Store( + "subjektiv Memory metadata/provenance leaves no room for resumable body content".into(), + ) + .into()); + } + Ok(()) +} + +fn set_subjektiv_read_body_page( + response: &mut server_api::SubjektivMemoryReadResponse, + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, + byte_limit: usize, +) { + let (body_md, body_next) = + bounded_memory_body_page(lines, line_offset, byte_offset, line_limit, byte_limit); + response.body_md = body_md; + response.body_truncated = body_next.is_some(); + (response.body_next_offset, response.body_next_byte_offset) = body_next + .map(|(line, byte)| (Some(line), Some(byte))) + .unwrap_or((None, None)); +} + fn bounded_memory_body_page( lines: &[&str], line_offset: usize, @@ -17580,7 +17700,7 @@ fn memory_body_lines(body: &str) -> Vec<&str> { } fn bound_subjektiv_nested_evidence( - candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate], + candidates: &[server_api::SubjektivMemoryEvidenceCandidate], ) -> ApiResult<()> { let serialized = serde_json::to_vec(candidates) .map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?; @@ -35673,6 +35793,7 @@ mod tests { nested_candidate, )) .unwrap(); + let escape_heavy_body = "\\\u{0000}\u{0001}".repeat(8_000); let (nested_memory, _) = store .apply_candidate( &subject.id, @@ -35681,7 +35802,7 @@ mod tests { crate::subjektiv::MemoryDraft::active( memory::extract::CandidateKind::Lesson, "Keep nested provenance bounded", - "bounded body", + escape_heavy_body.clone(), "Prevents unbounded read responses", "adopt oversized historical candidate", ), @@ -35692,7 +35813,7 @@ mod tests { &store, &subject.id, server_api::SubjektivMemoryReadRequest { - memory_id: nested_memory.id, + memory_id: nested_memory.id.clone(), revision: Some(1), offset: None, byte_offset: None, @@ -35703,12 +35824,15 @@ mod tests { .unwrap(); assert_eq!(nested_read.source_candidates.len(), 1); let nested = &nested_read.source_candidates[0]; - assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); + assert_eq!(nested.evidence.len(), SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE); assert_eq!(nested.evidence_total, nested_count); - assert!(!nested.evidence_truncated); - assert_eq!(nested.source_refs.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT); + assert!(nested.evidence_truncated); + assert_eq!( + nested.source_refs.len(), + SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE + ); assert_eq!(nested.source_refs_total, nested_count); - assert!(!nested.source_refs_truncated); + assert!(nested.source_refs_truncated); assert!(nested.evidence.iter().all(|item| { item.excerpt .as_ref() @@ -35737,7 +35861,195 @@ mod tests { .len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES ); - assert!(serde_json::to_vec(&nested_read).unwrap().len() < 64 * 1024); + assert!( + serde_json::to_vec_pretty(&nested_read).unwrap().len() + <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + ); + assert!(nested_read.body_truncated); + assert!(nested_read.body_next_offset.is_some()); + let mut nested_evidence_ids = nested + .evidence + .iter() + .map(|item| item.id.clone()) + .collect::>(); + let mut nested_source_sessions = nested + .source_refs + .iter() + .filter_map(|item| item.session_id.clone()) + .collect::>(); + let mut nested_cursor = nested_read.evidence_next_cursor.clone(); + while let Some(cursor) = nested_cursor { + let page = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: nested_memory.id.clone(), + revision: Some(1), + offset: None, + byte_offset: None, + limit: None, + evidence_cursor: Some(cursor), + }, + ) + .unwrap(); + let candidate = &page.source_candidates[0]; + nested_evidence_ids.extend(candidate.evidence.iter().map(|item| item.id.clone())); + nested_source_sessions.extend( + candidate + .source_refs + .iter() + .filter_map(|item| item.session_id.clone()), + ); + nested_cursor = page.evidence_next_cursor; + } + assert_eq!(nested_evidence_ids.len(), nested_count); + assert_eq!(nested_source_sessions.len(), nested_count); + + let mut reconstructed_body = nested_read.body_md.clone(); + let mut next_line = nested_read.body_next_offset; + let mut next_byte = nested_read.body_next_byte_offset; + while let Some(offset) = next_line { + let page = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: nested_memory.id.clone(), + revision: Some(1), + offset: Some(offset), + byte_offset: next_byte, + limit: None, + evidence_cursor: None, + }, + ) + .unwrap(); + assert!( + serde_json::to_vec_pretty(&page).unwrap().len() + <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + ); + reconstructed_body.push_str(&page.body_md); + next_line = page.body_next_offset; + next_byte = page.body_next_byte_offset; + } + assert_eq!(reconstructed_body, escape_heavy_body); + + // Simulate a row accepted before the write-admission anchor cap. The + // compatible schema remains readable, and its nested anchors advance + // through the ordinary evidence cursor instead of becoming orphaned. + let legacy_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 7; + let legacy_workspace_id = store.workspace_id().to_string(); + let legacy_evidence = (0..legacy_count) + .map(|index| memory::extract::StagingEvidence { + id: format!("legacy-evidence-{index}"), + kind: memory::schema::EvidenceKind::new("model_output"), + entry_range: Some([index as u64, index as u64]), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::ModelOutput, + account_id: None, + workspace_id: Some(legacy_workspace_id.clone()), + runtime_id: None, + worker_id: None, + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }), + excerpt: Some(format!("legacy excerpt {index}")), + summary: None, + }) + .collect::>(); + let legacy_refs = (0..legacy_count) + .map(|index| memory::schema::SourceEvidenceRef { + session_id: Some(format!("legacy-session-{index}")), + segment_id: Some("legacy-segment".to_string()), + entry_range: Some([index as u64, index as u64]), + evidence_id: Some(format!("legacy-evidence-{index}")), + evidence_kind: Some(memory::schema::EvidenceKind::new("model_output")), + origin: Some(memory::schema::EvidenceOrigin { + kind: memory::schema::EvidenceOriginKind::ModelOutput, + account_id: None, + workspace_id: Some(legacy_workspace_id.clone()), + runtime_id: None, + worker_id: None, + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }), + ..Default::default() + }) + .collect::>(); + let legacy_candidate = memory::extract::StagingRecord::from_candidate( + "candidate-legacy-over-anchor-limit", + "legacy-over-anchor-limit-dedup", + memory::schema::SourceRef { + segment_id: "legacy-segment".to_string(), + range: [0, legacy_count as u64 - 1], + }, + memory::extract::ExtractedCandidate { + kind: memory::extract::CandidateKind::Lesson, + claim: "Preserve compatible historical provenance".to_string(), + why_useful: "Upgrade must not orphan accepted anchors".to_string(), + staleness: None, + evidence_ids: legacy_evidence.iter().map(|item| item.id.clone()).collect(), + }, + legacy_evidence, + legacy_refs, + ); + let legacy_candidate = store + .stage_legacy_candidate_for_test(crate::subjektiv::SubjectStagingRecord::attach( + &subject.id, + legacy_candidate, + )) + .unwrap(); + let (legacy_memory, _) = store + .apply_candidate( + &subject.id, + &legacy_candidate.id, + crate::subjektiv::MemoryRevisionTarget::Create, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Preserve compatible historical provenance", + "legacy body", + "Upgrade must not orphan accepted anchors", + "upgrade fixture", + ), + "adopt legacy candidate", + ) + .unwrap(); + let mut legacy_cursor = None; + let mut retrieved_evidence = Vec::new(); + let mut retrieved_sessions = Vec::new(); + loop { + let page = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: legacy_memory.id.clone(), + revision: Some(1), + offset: None, + byte_offset: None, + limit: None, + evidence_cursor: legacy_cursor, + }, + ) + .unwrap(); + let candidate = &page.source_candidates[0]; + retrieved_evidence.extend(candidate.evidence.iter().map(|item| item.id.clone())); + retrieved_sessions.extend( + candidate + .source_refs + .iter() + .filter_map(|item| item.session_id.clone()), + ); + if !page.evidence_has_more { + break; + } + legacy_cursor = page.evidence_next_cursor; + } + assert_eq!(retrieved_evidence.len(), legacy_count); + assert_eq!(retrieved_sessions.len(), legacy_count); + for index in 0..legacy_count { + assert!(retrieved_evidence.contains(&format!("legacy-evidence-{index}"))); + assert!(retrieved_sessions.contains(&format!("legacy-session-{index}"))); + } let revisions = subjektiv_memory_list_revisions( &store, diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index b27aac270..9c3d4c59b 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -899,6 +899,7 @@ impl SubjektivStore { pub fn stage_candidate(&self, record: SubjectStagingRecord) -> Result { validate_staging_record(self.workspace_id(), &record)?; + validate_staging_admission(&record)?; let raw = serde_json::to_string(&record)?; self.database.try_transaction(|transaction| { require_active_subject(transaction, &record.subject_id)?; @@ -926,6 +927,7 @@ impl SubjektivStore { } attach_session_to_candidate(&mut record, &attribution.session_id)?; validate_staging_record(self.workspace_id(), &record)?; + validate_staging_admission(&record)?; let candidate_raw = serde_json::to_string(&record)?; let attribution_raw = serde_json::to_string(&attribution)?; @@ -938,6 +940,21 @@ impl SubjektivStore { }) } + /// Inserts a structurally valid record using the pre-admission-limit path so + /// upgrade tests can prove that compatible historical rows stay readable. + #[cfg(test)] + pub(crate) fn stage_legacy_candidate_for_test( + &self, + record: SubjectStagingRecord, + ) -> Result { + validate_staging_record(self.workspace_id(), &record)?; + let raw = serde_json::to_string(&record)?; + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &record.subject_id)?; + write_staging_candidate(transaction, self.workspace_id(), record, raw) + }) + } + pub fn record_session_attribution( &self, attribution: SubjectSessionAttribution, @@ -1899,12 +1916,6 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> )); } - if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS - { - return Err(SubjektivError::InvalidRecord(format!( - "staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each" - ))); - } let mut evidence_ids = HashSet::new(); for evidence in &record.evidence { validate_label("evidence id", &evidence.id)?; @@ -1967,6 +1978,19 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Ok(()) } +/// Applies the current write-admission contract separately from structural +/// validation. Persisted rows from older compatible implementations remain +/// readable even when a later release tightens admission limits. +fn validate_staging_admission(record: &SubjectStagingRecord) -> Result<()> { + if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS + { + return Err(SubjektivError::InvalidRecord(format!( + "staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each" + ))); + } + Ok(()) +} + fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> { validate_label("revision proposal Memory id", &proposal.memory_id)?; if proposal.expected_revision == 0 { diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 264b93d23..44a0a5554 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -184,17 +184,21 @@ page members. Read accepts a positive exact revision or resolves the current revision once. A missing historical revision never falls back to current. Markdown pagination is -line-based (default 200, maximum 1000) with an additional 16 KiB UTF-8 response -budget. It reports `body_truncated`, `body_next_offset`, and -`body_next_byte_offset`; an oversized single line resumes from the returned byte -boundary within that same line. Every continuation offset must also supply the exact -revision returned by the first page, so a current-revision change cannot mix body -versions. Provenance uses a separate immutable-revision-bound cursor and returns one -candidate/derivation reference per page. A staged candidate contains at most 10 -evidence records and 10 source references, all returned together under a hard 40 KiB -serialized budget for the expanded candidate array; displayed anchor text is capped -at 64 UTF-8 bytes. Every staging path rejects larger anchor sets, so accepted -provenance is never made unreachable. Candidate evidence is the bounded +line-based (default 200, maximum 1000) with a preliminary 16 KiB UTF-8 body +cap and a global 56 KiB budget over the exact pretty-serialized model-visible +response. JSON escaping, provenance, and metadata therefore reduce the current +body page before the Worker content ceiling is reached. It reports +`body_truncated`, `body_next_offset`, and `body_next_byte_offset`; an oversized +or escape-heavy single line resumes from the returned UTF-8 byte boundary within +that same line. Every continuation offset must also supply the exact revision +returned by the first page, so a current-revision change cannot mix body versions. +Provenance uses a separate immutable-revision-bound cursor and returns one +candidate/derivation reference per page. New staged candidates contain at most 10 +evidence records and 10 source references; read pages project at most two of each, +with displayed anchor text capped at 64 UTF-8 bytes and nested offsets carried by +the ordinary evidence cursor. Compatible candidates persisted before the admission +cap remain readable across as many evidence pages as needed, while every current +staging path rejects new larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied into Memory responses. Resolved, retracted, and historical revisions remain addressable by ID. From 2c329072a241a1683caaa965d57d2d56c7f9b93f Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 10:40:45 +0900 Subject: [PATCH 10/31] Replace Worker list revisions with ordered subscriptions --- crates/protocol/src/subscription.rs | 21 +- crates/protocol/src/typescript.rs | 3 - crates/worker-runtime/src/http_server.rs | 2 - crates/worker-runtime/src/lib.rs | 5 +- crates/worker-runtime/src/runtime.rs | 158 +++-- crates/workspace-server/src/latest_schema.sql | 16 - .../src/runtime_subscription.rs | 315 ++++---- .../src/runtime_subscription_tests.rs | 80 ++- crates/workspace-server/src/server.rs | 176 +++-- crates/workspace-server/src/store.rs | 670 +++++++++--------- .../workspace-server/src/worker_projection.rs | 428 ++++++++--- .../src/workspace_deletion.rs | 2 - .../src/workspace_subscription.rs | 31 +- .../console_history_fixture_server.ts | 3 +- .../memory_document_fixture_server.ts | 3 +- .../workspace_home_fixture_server.ts | 3 +- .../src/lib/generated/protocol-validator.ts | 6 +- web/workspace/src/lib/generated/protocol.ts | 10 +- .../lib/workspace/multiplexer.browser.spec.ts | 7 +- .../src/lib/workspace/multiplexer.ts | 6 +- .../lib/workspace/protocol-validator.test.ts | 30 +- .../sidebar/worker-stop.browser.spec.ts | 4 +- .../sidebar/worker-subscription-model.ts | 38 +- .../sidebar/worker-subscription.test.ts | 266 ++++--- 24 files changed, 1320 insertions(+), 963 deletions(-) diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index 8a036b05b..642a1528d 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::Event as WorkerProtocolEvent; -pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1; +pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 2; /// Browser/runtime wire guardrails. These bounds are generated into the Workspace /// Browser validator so aggregate limits remain part of the Rust-owned contract. pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024; @@ -426,7 +426,6 @@ pub enum SubscriptionResponse { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, - snapshot_revision: u64, snapshot: SubscriptionSnapshot, }, Unsubscribed { @@ -512,7 +511,6 @@ pub enum SubscriptionTerminationCode { pub enum SubscriptionEvent { Event { subscription_id: SubscriptionId, - subject_revision: u64, payload: SubscriptionEventPayload, }, SubscriptionClosed { @@ -666,9 +664,7 @@ pub struct SubscriptionWorker { /// `Unavailable` preserves catalog membership without claiming that execution stopped. #[serde(default)] pub availability: SubscriptionWorkerAvailability, - /// Producer-owned monotonic revision for this Worker subject. - pub subject_revision: u64, - /// Latest revisioned foreground state observed from the Worker. This remains + /// Latest foreground state observed from the Worker. This remains /// absent until an authoritative Worker snapshot/event has been applied. #[serde(default, skip_serializing_if = "Option::is_none")] pub worker_state: Option, @@ -970,7 +966,6 @@ mod tests { runtime_id: None, resource_key: None, availability: SubscriptionWorkerAvailability::Observed, - subject_revision: 0, worker_state: None, state: SubscriptionWorkerState::Idle, has_running_internal_workers: false, @@ -1093,7 +1088,7 @@ mod tests { assert_eq!( json, serde_json::json!({ - "protocol_version": 1, + "protocol_version": 2, "frame": "request", "message": { "method": "subscribe_events", @@ -1119,7 +1114,6 @@ mod tests { request_id: request_id(), subscription_id: subscription_id(), selector: EventSubscriptionSelector::RuntimeWorkers, - snapshot_revision: 7, snapshot: SubscriptionSnapshot::Workers { workers: vec![worker("worker-1")], }, @@ -1158,10 +1152,10 @@ mod tests { subscription_id: subscription_id(), }, )); - frame.protocol_version = 2; + frame.protocol_version = 1; assert!(matches!( frame.validate(), - Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 2 }) + Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 1 }) )); } @@ -1180,7 +1174,6 @@ mod tests { let event = SubscriptionEvent::Event { subscription_id: subscription_id(), - subject_revision: 8, payload: SubscriptionEventPayload::WorkerRemoved { worker_id: worker_id("worker-2"), runtime_id: None, @@ -1211,7 +1204,7 @@ mod tests { assert_eq!( serde_json::to_value(frame).unwrap(), serde_json::json!({ - "protocol_version": 1, + "protocol_version": 2, "frame": "worker_protocol", "message": { "subscription_id": "subscription-1", @@ -1253,7 +1246,7 @@ mod tests { assert_eq!( serde_json::to_value(frame).unwrap(), serde_json::json!({ - "protocol_version": 1, + "protocol_version": 2, "frame": "event", "message": { "event": "subscription_closed", diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 281e8ece6..5535bb557 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -230,7 +230,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { runtime_id: Some("fixture-runtime".to_string()), resource_key: Some("fixture-worker-resource".to_string()), availability: SubscriptionWorkerAvailability::default(), - subject_revision: 1, worker_state: None, state: SubscriptionWorkerState::Idle, has_running_internal_workers: false, @@ -250,7 +249,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { request_id, subscription_id: subscription_id.clone(), selector: EventSubscriptionSelector::WorkspaceWorkers, - snapshot_revision: 1, snapshot: SubscriptionSnapshot::Workers { workers: vec![worker], }, @@ -259,7 +257,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { let pending = SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { subscription_id, - subject_revision: 2, payload: SubscriptionEventPayload::WorkerProtocol { worker_id, event: Event::PendingSubmissionsChanged { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 9c1a4f7fa..e3b5ca2c3 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -1334,7 +1334,6 @@ async fn runtime_protocol_ws_session( request_id, subscription_id: subscription_id.clone(), selector: selector.clone(), - snapshot_revision: subscription.snapshot_revision(), snapshot: subscription.snapshot().clone(), }, )); @@ -1354,7 +1353,6 @@ async fn runtime_protocol_ws_session( SubscriptionFramePayload::Event( SubscriptionEvent::Event { subscription_id: event_subscription_id.clone(), - subject_revision: update.subject_revision, payload: update.payload, }, ), diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index a2b49dcb7..9c7c3df04 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -36,5 +36,8 @@ pub mod workspace_request; #[cfg(feature = "fs-store")] pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use management::RuntimeOptions; -pub use runtime::{Runtime, RuntimeWorkspaceScope}; +pub use runtime::{ + Runtime, RuntimeEventSelectorSubscription, RuntimeSubscriptionRecvError, + RuntimeSubscriptionUpdate, RuntimeWorkspaceScope, +}; pub use session_store::UploadedFileUploadContext; diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index f16d5f603..c80787cf3 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -151,7 +151,6 @@ impl RuntimeWorkerRestoreResult { #[derive(Clone, Debug)] pub struct RuntimeSubscriptionUpdate { - pub subject_revision: u64, pub payload: SubscriptionEventPayload, } @@ -168,7 +167,6 @@ pub enum RuntimeSubscriptionRecvError { pub struct RuntimeEventSelectorSubscription { subscription_id: u64, selector: EventSubscriptionSelector, - snapshot_revision: u64, snapshot: SubscriptionSnapshot, receiver: mpsc::Receiver, lagged: Arc, @@ -180,10 +178,6 @@ impl RuntimeEventSelectorSubscription { &self.selector } - pub fn snapshot_revision(&self) -> u64 { - self.snapshot_revision - } - pub fn snapshot(&self) -> &SubscriptionSnapshot { &self.snapshot } @@ -1280,7 +1274,6 @@ impl Runtime { state.persist_runtime_snapshot()?; } let snapshot = state.subscription_snapshot(scope, &selector)?; - let snapshot_revision = state.subscription_revision; let subscription_id = state.next_event_subscription_id; state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1); let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY); @@ -1298,7 +1291,6 @@ impl Runtime { Ok(RuntimeEventSelectorSubscription { subscription_id, selector, - snapshot_revision, snapshot, receiver, lagged, @@ -2217,9 +2209,9 @@ impl Runtime { if let Some(payload) = input_protocol_event(&input) { state.push_worker_observation_event(worker_ref.clone(), payload); } - state.publish_worker_upsert(worker_ref.worker_id)?; state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; + state.publish_worker_upsert(worker_ref.worker_id)?; Ok(WorkerInteractionAck { worker_ref: worker_ref.clone(), @@ -2500,9 +2492,9 @@ impl Runtime { worker.workdir_attachments = workdir_attachments; worker.detail() }; - state.publish_worker_upsert(worker_ref.worker_id)?; state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; + state.publish_worker_upsert(worker_ref.worker_id)?; Ok(detail) } @@ -2541,8 +2533,8 @@ impl Runtime { state .observation_events .retain(|event| event.worker_ref != *worker_ref); - state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?; state.persist_runtime_snapshot()?; + state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?; } Ok(()) } @@ -2564,9 +2556,9 @@ impl Runtime { let mut state = self.lock()?; let worker = state.worker_mut(worker_ref)?; worker.apply_worker_state(&snapshot); - state.publish_worker_upsert(worker_ref.worker_id)?; state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; + state.publish_worker_upsert(worker_ref.worker_id)?; Ok(()) } @@ -2870,8 +2862,8 @@ impl Runtime { state .observation_events .retain(|event| event.worker_ref != *worker_ref); - state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?; state.persist_runtime_snapshot()?; + state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?; Ok(WorkerDeleteResult { worker_id: removed.worker_id, deleted: true, @@ -2959,13 +2951,13 @@ impl Runtime { let worker_state_changed = state.project_protocol_event_to_worker_state(worker_ref, &payload); let activity_changed = state.project_internal_worker_activity(worker_ref, &payload); - if worker_state_changed || activity_changed { - state.publish_worker_upsert(worker_ref.worker_id)?; - } if worker_state_changed { state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; } + if worker_state_changed || activity_changed { + state.publish_worker_upsert(worker_ref.worker_id)?; + } let event = state.push_worker_observation_event(worker_ref.clone(), payload); Ok(event) } @@ -3021,13 +3013,13 @@ impl Runtime { state.project_protocol_event_to_worker_state(&worker_ref, &payload); let activity_changed = state.project_internal_worker_activity(&worker_ref, &payload); - if worker_state_changed || activity_changed { - state.publish_worker_upsert(worker_ref.worker_id)?; - } if worker_state_changed { state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; } + if worker_state_changed || activity_changed { + state.publish_worker_upsert(worker_ref.worker_id)?; + } Ok(state.push_worker_observation_event(worker_ref, payload)) }), ) @@ -3054,13 +3046,13 @@ impl Runtime { state.project_protocol_event_to_worker_state(&worker_ref, &payload); let activity_changed = state.project_internal_worker_activity(&worker_ref, &payload); - if worker_state_changed || activity_changed { - state.publish_worker_upsert(worker_ref.worker_id)?; - } if worker_state_changed { state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; } + if worker_state_changed || activity_changed { + state.publish_worker_upsert(worker_ref.worker_id)?; + } Ok(state.push_worker_observation_event(worker_ref, payload)) }), ) @@ -3454,8 +3446,8 @@ impl Runtime { logical_workdir_attachments_from_statuses(&workdir_attachments, claims); candidate.workdir_attachments = workdir_attachments; state.workers.insert(worker_ref.worker_id, candidate); - state.publish_worker_upsert(worker_ref.worker_id)?; state.persist_worker(&worker_ref.worker_id)?; + state.publish_worker_upsert(worker_ref.worker_id)?; Ok(()) } @@ -3702,8 +3694,6 @@ struct RuntimeState { workspace_config_latest: BTreeMap, workspace_config_fetch_gates: BTreeMap>>, diagnostics: Vec, - subscription_revision: u64, - worker_subject_revisions: BTreeMap, next_event_subscription_id: u64, subscriptions: BTreeMap, #[cfg(feature = "ws-server")] @@ -3733,8 +3723,6 @@ impl RuntimeState { workspace_config_latest: BTreeMap::new(), workspace_config_fetch_gates: BTreeMap::new(), diagnostics: Vec::new(), - subscription_revision: 0, - worker_subject_revisions: BTreeMap::new(), next_event_subscription_id: 1, subscriptions: BTreeMap::new(), #[cfg(feature = "ws-server")] @@ -3765,8 +3753,6 @@ impl RuntimeState { workspace_config_latest: BTreeMap::new(), workspace_config_fetch_gates: BTreeMap::new(), diagnostics: Vec::new(), - subscription_revision: 0, - worker_subject_revisions: BTreeMap::new(), next_event_subscription_id: 1, subscriptions: BTreeMap::new(), #[cfg(feature = "ws-server")] @@ -3895,8 +3881,6 @@ impl RuntimeState { workspace_config_fetch_gates: BTreeMap::new(), workspace_owners: persisted.workspace_owners, diagnostics, - subscription_revision: 0, - worker_subject_revisions: BTreeMap::new(), next_event_subscription_id: 1, subscriptions: BTreeMap::new(), #[cfg(feature = "ws-server")] @@ -4215,11 +4199,6 @@ impl RuntimeState { runtime_id: None, resource_key: None, availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, - subject_revision: self - .worker_subject_revisions - .get(&worker.worker_id) - .copied() - .unwrap_or(0), worker_state: worker.worker_state.clone(), state: subscription_worker_state(worker.status), has_running_internal_workers: worker.internal_workers.has_running_worker(), @@ -4232,25 +4211,17 @@ impl RuntimeState { } fn publish_worker_upsert(&mut self, worker_id: WorkerId) -> Result<(), RuntimeError> { - self.subscription_revision = self.subscription_revision.saturating_add(1); - let subject_revision = { - let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0); - *revision = revision.saturating_add(1); - *revision - }; let worker = self .workers .get(&worker_id) .ok_or(RuntimeError::WorkerNotFound { worker_id })?; let workspace_id = worker.workspace_id.clone(); - let mut projected = self.subscription_worker(worker)?; - projected.subject_revision = subject_revision; + let projected = self.subscription_worker(worker)?; let projected_worker_id = projected.worker_id.clone(); self.deliver_worker_subscription_update( &projected_worker_id, workspace_id.as_deref(), RuntimeSubscriptionUpdate { - subject_revision, payload: SubscriptionEventPayload::WorkerUpserted { worker: projected }, }, ); @@ -4262,19 +4233,12 @@ impl RuntimeState { worker_id: WorkerId, workspace_id: Option<&str>, ) -> Result<(), RuntimeError> { - self.subscription_revision = self.subscription_revision.saturating_add(1); - let subject_revision = { - let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0); - *revision = revision.saturating_add(1); - *revision - }; let worker_id = SubscriptionWorkerId::new(worker_id.to_string()) .map_err(subscription_validation_error)?; self.deliver_worker_subscription_update( &worker_id, workspace_id, RuntimeSubscriptionUpdate { - subject_revision, payload: SubscriptionEventPayload::WorkerRemoved { worker_id: worker_id.clone(), runtime_id: None, @@ -4417,8 +4381,8 @@ impl RuntimeState { worker_ref: Some(worker_ref.clone()), }); self.workers.insert(worker_ref.worker_id, candidate); - self.publish_worker_upsert(worker_ref.worker_id)?; self.persist_runtime_snapshot()?; + self.publish_worker_upsert(worker_ref.worker_id)?; Ok(()) } @@ -6637,7 +6601,6 @@ mod tests { let mut subscription = runtime .subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers) .unwrap(); - assert_eq!(subscription.snapshot_revision(), 0); let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else { panic!("runtime_workers must return a Worker snapshot"); }; @@ -6645,11 +6608,9 @@ mod tests { let created = runtime.create_worker(task_request("live")).unwrap(); let update = receive_subscription_update(&mut subscription).unwrap(); - assert_eq!(update.subject_revision, 1); match update.payload { SubscriptionEventPayload::WorkerUpserted { worker } => { assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string()); - assert_eq!(worker.subject_revision, 1); assert_eq!(worker.state, SubscriptionWorkerState::Idle); } payload => panic!("unexpected subscription payload: {payload:?}"), @@ -6666,7 +6627,6 @@ mod tests { ) .unwrap(); let update = receive_subscription_update(&mut subscription).unwrap(); - assert_eq!(update.subject_revision, 2); match update.payload { SubscriptionEventPayload::WorkerUpserted { worker } => { assert_eq!(worker.state, SubscriptionWorkerState::Idle); @@ -6685,7 +6645,6 @@ mod tests { ) .unwrap(); let update = receive_subscription_update(&mut subscription).unwrap(); - assert_eq!(update.subject_revision, 3); match update.payload { SubscriptionEventPayload::WorkerUpserted { worker } => { assert_eq!(worker.state, SubscriptionWorkerState::Idle); @@ -6727,7 +6686,6 @@ mod tests { ) .unwrap(); let update = receive_subscription_update(&mut subscription).unwrap(); - assert_eq!(update.subject_revision, 4); match update.payload { SubscriptionEventPayload::WorkerUpserted { worker } => { assert_eq!(worker.state, SubscriptionWorkerState::Idle); @@ -6738,7 +6696,6 @@ mod tests { runtime.stop_worker(&created.worker_ref, None).unwrap(); let update = receive_subscription_update(&mut subscription).unwrap(); - assert_eq!(update.subject_revision, 5); match update.payload { SubscriptionEventPayload::WorkerUpserted { worker } => { assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string()); @@ -6750,6 +6707,87 @@ mod tests { runtime.stop_runtime().unwrap(); } + #[test] + fn concurrent_snapshot_and_worker_creation_cross_the_boundary_exactly_once() { + let runtime = runtime_with_backend(); + let barrier = Arc::new(Barrier::new(2)); + let subscribing_runtime = runtime.clone(); + let subscribing_barrier = barrier.clone(); + let subscriber = std::thread::spawn(move || { + subscribing_barrier.wait(); + subscribing_runtime + .subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers) + .unwrap() + }); + let creating_runtime = runtime.clone(); + let creating_barrier = barrier.clone(); + let creator = std::thread::spawn(move || { + creating_barrier.wait(); + creating_runtime + .create_worker(task_request("concurrent-boundary")) + .unwrap() + }); + + let mut subscription = subscriber.join().unwrap(); + let created = creator.join().unwrap(); + let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else { + panic!("runtime_workers must return a Worker snapshot"); + }; + let in_snapshot = workers + .iter() + .any(|worker| worker.worker_id.as_str() == created.worker_id.to_string()); + let in_event = match subscription.receiver.try_recv() { + Ok(RuntimeSubscriptionUpdate { + payload: SubscriptionEventPayload::WorkerUpserted { worker }, + }) => worker.worker_id.as_str() == created.worker_id.to_string(), + Ok(update) => panic!("unexpected subscription update: {update:?}"), + Err(mpsc::error::TryRecvError::Empty) => false, + Err(error) => panic!("unexpected subscription receive error: {error}"), + }; + assert_ne!( + in_snapshot, in_event, + "the concurrent creation must appear in the snapshot or its ordered suffix, never both" + ); + } + + #[cfg(feature = "fs-store")] + #[test] + fn restarted_runtime_subscription_starts_from_persisted_authoritative_snapshot() { + let root = tempfile::tempdir().unwrap(); + let options = crate::fs_store::FsRuntimeStoreOptions { + root: root.path().join("runtime"), + runtime_id: "subscription-restart".to_string(), + display_name: None, + }; + let runtime = Runtime::with_fs_store_and_execution_backend( + options.clone(), + Arc::new(TestExecutionBackend::default()), + ) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let worker = runtime + .create_worker(task_request("persisted-subscription-worker")) + .unwrap(); + drop(runtime); + + let restarted = Runtime::with_fs_store_and_execution_backend( + options, + Arc::new(TestExecutionBackend::default()), + ) + .unwrap(); + let subscription = restarted + .subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers) + .unwrap(); + let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else { + panic!("runtime_workers must return a Worker snapshot"); + }; + assert!(workers.iter().any(|candidate| { + candidate.worker_id.as_str() == worker.worker_id.to_string() + && candidate.availability + == protocol::subscription::SubscriptionWorkerAvailability::Observed + })); + } + #[test] fn worker_lifecycle_subscription_delivers_only_selected_workers() { let runtime = runtime_with_backend(); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index f5f021c53..1ba828b75 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -914,31 +914,15 @@ CREATE TABLE worker_registry_observations ( worker_id TEXT NOT NULL, availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), worker_json TEXT, - connection_generation INTEGER NOT NULL, - subject_revision INTEGER NOT NULL, - snapshot_revision INTEGER NOT NULL, - projection_revision INTEGER NOT NULL, observed_at TEXT NOT NULL, PRIMARY KEY (workspace_id, runtime_id, worker_id), FOREIGN KEY (workspace_id, worker_id) REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE ); -CREATE TABLE worker_registry_projection_cursors ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - connection_generation INTEGER NOT NULL, - snapshot_revision INTEGER NOT NULL, - PRIMARY KEY (workspace_id, runtime_id) -); -CREATE TABLE worker_registry_projection_revisions ( - workspace_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL -); CREATE TABLE worker_registry_projection_removals ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, - projection_revision INTEGER NOT NULL, PRIMARY KEY (workspace_id, runtime_id, worker_id) ); CREATE TABLE worker_registry_projection_diagnostics ( diff --git a/crates/workspace-server/src/runtime_subscription.rs b/crates/workspace-server/src/runtime_subscription.rs index 5b5eb6bcf..3a7fdd0bd 100644 --- a/crates/workspace-server/src/runtime_subscription.rs +++ b/crates/workspace-server/src/runtime_subscription.rs @@ -33,26 +33,19 @@ pub enum RuntimeSubscriptionBrokerError { #[derive(Clone, Debug)] pub enum BrokerSubscriptionEvent { Snapshot { - connection_generation: u64, - snapshot_revision: u64, snapshot: SubscriptionSnapshot, }, Event { - connection_generation: u64, - subject_revision: u64, payload: SubscriptionEventPayload, }, Disconnected { - connection_generation: u64, message: String, }, Rejected { - connection_generation: u64, code: SubscriptionRejectionCode, message: String, }, Closed { - connection_generation: u64, code: SubscriptionTerminationCode, message: String, }, @@ -60,7 +53,6 @@ pub enum BrokerSubscriptionEvent { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RuntimeSubscriptionBrokerStatus { - pub connection_generation: u64, pub connected: bool, pub desired_selectors: usize, pub upstream_subscriptions: usize, @@ -82,7 +74,30 @@ impl BrokerSubscription { &self.selector } pub async fn recv(&mut self) -> Option { - self.receiver.recv().await + loop { + if let Some(event) = self.receiver.recv().await { + return Some(event); + } + if self.restart().is_err() { + return None; + } + } + } + + pub(crate) fn restart(&mut self) -> Result<(), RuntimeSubscriptionBrokerError> { + self.commands + .send(Command::Unsubscribe(self.downstream_id)) + .map_err(|_| RuntimeSubscriptionBrokerError::Closed)?; + let (events, receiver) = mpsc::channel(DOWNSTREAM_QUEUE_CAPACITY); + self.commands + .send(Command::Subscribe { + downstream_id: self.downstream_id, + selector: self.selector.clone(), + events, + }) + .map_err(|_| RuntimeSubscriptionBrokerError::Closed)?; + self.receiver = receiver; + Ok(()) } } @@ -120,10 +135,7 @@ impl RuntimeSubscriptionBroker { pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let (commands, receiver) = mpsc::unbounded_channel(); - let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus { - connection_generation: generation, - ..Default::default() - })); + let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus::default())); let registration = Registration { generation, commands: commands.clone(), @@ -156,7 +168,6 @@ impl RuntimeSubscriptionBroker { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let (commands, receiver) = mpsc::unbounded_channel(); let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus { - connection_generation: generation, connected: true, ..Default::default() })); @@ -257,7 +268,6 @@ impl RuntimeSubscriptionBroker { .clone(); if !initial_status.connected { let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected { - connection_generation: initial_status.connection_generation, message: "Runtime subscription connection is not currently available".to_string(), }); } @@ -286,8 +296,7 @@ struct SelectorState { downstreams: HashMap>, upstream_id: Option, pending: bool, - snapshot: Option<(u64, SubscriptionSnapshot)>, - revisions: HashMap, + snapshot: Option, } impl SelectorState { fn new() -> Self { @@ -296,7 +305,6 @@ impl SelectorState { upstream_id: None, pending: false, snapshot: None, - revisions: HashMap::new(), } } } @@ -334,11 +342,9 @@ impl State { selector.upstream_id = None; selector.pending = false; selector.snapshot = None; - selector.revisions.clear(); broadcast( &mut selector.downstreams, BrokerSubscriptionEvent::Disconnected { - connection_generation: self.generation, message: message.clone(), }, ); @@ -347,12 +353,77 @@ impl State { } struct EmbeddedEntry { + lifetime: u64, downstreams: HashMap>, - snapshot_revision: u64, snapshot: SubscriptionSnapshot, task: tokio::task::JoinHandle<()>, } +enum EmbeddedUpdate { + Event { + selector: EventSubscriptionSelector, + lifetime: u64, + payload: SubscriptionEventPayload, + }, + Closed { + selector: EventSubscriptionSelector, + lifetime: u64, + code: SubscriptionTerminationCode, + message: String, + }, +} + +fn start_embedded_subscription( + runtime: &worker_runtime::Runtime, + runtime_id: &str, + selector: EventSubscriptionSelector, + lifetime: u64, + updates: mpsc::UnboundedSender, +) -> Result<(SubscriptionSnapshot, tokio::task::JoinHandle<()>), worker_runtime::error::RuntimeError> +{ + let mut subscription = runtime.subscribe_event_selector(selector.clone())?; + let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), runtime_id); + let task_runtime_id = runtime_id.to_string(); + let task = tokio::spawn(async move { + loop { + match subscription.recv().await { + Ok(update) => { + let payload = project_payload_runtime(update.payload, &task_runtime_id); + if updates + .send(EmbeddedUpdate::Event { + selector: selector.clone(), + lifetime, + payload, + }) + .is_err() + { + return; + } + } + Err(worker_runtime::RuntimeSubscriptionRecvError::Lagged) => { + let _ = updates.send(EmbeddedUpdate::Closed { + selector, + lifetime, + code: SubscriptionTerminationCode::Lagged, + message: "embedded Runtime subscription lagged; resubscribing from a fresh snapshot".to_string(), + }); + return; + } + Err(worker_runtime::RuntimeSubscriptionRecvError::Closed) => { + let _ = updates.send(EmbeddedUpdate::Closed { + selector, + lifetime, + code: SubscriptionTerminationCode::ServerShutdown, + message: "embedded Runtime subscription closed".to_string(), + }); + return; + } + } + } + }); + Ok((snapshot, task)) +} + async fn run_embedded_connection( runtime_id: String, runtime: worker_runtime::Runtime, @@ -364,6 +435,7 @@ async fn run_embedded_connection( let (updates, mut update_receiver) = mpsc::unbounded_channel(); let mut entries = HashMap::::new(); let mut downstream_index = HashMap::::new(); + let mut next_lifetime = 1_u64; loop { tokio::select! { command = commands.recv() => match command { @@ -371,30 +443,38 @@ async fn run_embedded_connection( downstream_index.insert(downstream_id, selector.clone()); if let Some(entry) = entries.get_mut(&selector) { let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { - connection_generation: generation, - snapshot_revision: entry.snapshot_revision, snapshot: entry.snapshot.clone(), }); entry.downstreams.insert(downstream_id, events); } else { - match runtime.subscribe_event_selector(selector.clone()) { - Ok(mut subscription) => { - let snapshot_revision = subscription.snapshot_revision(); - let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id); - let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() }); - let sender = updates.clone(); - let task_selector = selector.clone(); - let task_runtime_id = runtime_id.clone(); - let task = tokio::spawn(async move { - while let Ok(update) = subscription.recv().await { - let payload = project_payload_runtime(update.payload, &task_runtime_id); - if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; } - } + let lifetime = next_lifetime; + next_lifetime = next_lifetime.saturating_add(1); + match start_embedded_subscription( + &runtime, + &runtime_id, + selector.clone(), + lifetime, + updates.clone(), + ) { + Ok((snapshot, task)) => { + let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { + snapshot: snapshot.clone(), }); - entries.insert(selector, EmbeddedEntry { downstreams: HashMap::from([(downstream_id, events)]), snapshot_revision, snapshot, task }); + entries.insert( + selector, + EmbeddedEntry { + lifetime, + downstreams: HashMap::from([(downstream_id, events)]), + snapshot, + task, + }, + ); } Err(error) => { - let _ = events.try_send(BrokerSubscriptionEvent::Rejected { connection_generation: generation, code: SubscriptionRejectionCode::UnsupportedSelector, message: error.to_string() }); + let _ = events.try_send(BrokerSubscriptionEvent::Rejected { + code: SubscriptionRejectionCode::UnsupportedSelector, + message: error.to_string(), + }); } } } @@ -407,7 +487,7 @@ async fn run_embedded_connection( } Some(Command::Shutdown(replacement)) => { for entry in entries.values_mut() { - broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { connection_generation: generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime generation {generation} was fenced by {replacement}") }); + broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime registration {generation} was replaced by {replacement}") }); entry.task.abort(); } return; @@ -415,16 +495,68 @@ async fn run_embedded_connection( None => return, }, update = update_receiver.recv() => { - let Some((selector, subject_revision, payload)) = update else { return; }; - if let Some(entry) = entries.get_mut(&selector) { - entry.snapshot_revision = entry.snapshot_revision.saturating_add(1); - apply_event_to_cached_snapshot(&mut entry.snapshot, &payload); - broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, payload }); + let Some(update) = update else { return; }; + match update { + EmbeddedUpdate::Event { selector, lifetime, payload } => { + if let Some(entry) = entries + .get_mut(&selector) + .filter(|entry| entry.lifetime == lifetime) + { + apply_event_to_cached_snapshot(&mut entry.snapshot, &payload); + broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { payload }); + } + } + EmbeddedUpdate::Closed { selector, lifetime, code, message } => { + if !entries + .get(&selector) + .is_some_and(|entry| entry.lifetime == lifetime) + { + continue; + } + let Some(mut entry) = entries.remove(&selector) else { continue; }; + broadcast( + &mut entry.downstreams, + BrokerSubscriptionEvent::Closed { code, message }, + ); + if entry.downstreams.is_empty() { + continue; + } + let new_lifetime = next_lifetime; + next_lifetime = next_lifetime.saturating_add(1); + match start_embedded_subscription( + &runtime, + &runtime_id, + selector.clone(), + new_lifetime, + updates.clone(), + ) { + Ok((snapshot, task)) => { + broadcast( + &mut entry.downstreams, + BrokerSubscriptionEvent::Snapshot { + snapshot: snapshot.clone(), + }, + ); + entry.snapshot = snapshot; + entry.task = task; + entry.lifetime = new_lifetime; + entries.insert(selector, entry); + } + Err(error) => { + broadcast( + &mut entry.downstreams, + BrokerSubscriptionEvent::Rejected { + code: SubscriptionRejectionCode::Internal, + message: error.to_string(), + }, + ); + } + } + } } } } *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { - connection_generation: generation, connected: true, desired_selectors: entries.len(), upstream_subscriptions: entries.len(), @@ -597,10 +729,8 @@ async fn apply_online( .selectors .entry(selector.clone()) .or_insert_with(SelectorState::new); - if let Some((revision, snapshot)) = &entry.snapshot { + if let Some(snapshot) = &entry.snapshot { let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { - connection_generation: state.generation, - snapshot_revision: *revision, snapshot: snapshot.clone(), }); } @@ -696,7 +826,6 @@ async fn handle_frame( request_id, subscription_id, selector, - snapshot_revision, snapshot, }) => { if state.pending.remove(&request_id) != Some(selector.clone()) { @@ -706,18 +835,13 @@ async fn handle_frame( let entry = state.selectors.get_mut(&selector).ok_or(())?; entry.pending = false; entry.upstream_id = Some(subscription_id.clone()); - entry.snapshot = Some((snapshot_revision, snapshot.clone())); - entry.revisions = snapshot_revisions(&snapshot); + entry.snapshot = Some(snapshot.clone()); state .upstream_index .insert(subscription_id, selector.clone()); broadcast( &mut entry.downstreams, - BrokerSubscriptionEvent::Snapshot { - connection_generation: state.generation, - snapshot_revision, - snapshot, - }, + BrokerSubscriptionEvent::Snapshot { snapshot }, ); if entry.downstreams.is_empty() { maybe_unsubscribe(socket, state, selector).await?; @@ -734,18 +858,13 @@ async fn handle_frame( if let Some(mut entry) = state.selectors.remove(&selector) { broadcast( &mut entry.downstreams, - BrokerSubscriptionEvent::Rejected { - connection_generation: state.generation, - code, - message, - }, + BrokerSubscriptionEvent::Rejected { code, message }, ); } } } SubscriptionFramePayload::Event(SubscriptionEvent::Event { subscription_id, - subject_revision, payload, }) => { let selector = state @@ -756,24 +875,12 @@ async fn handle_frame( payload.validate_for_selector(&selector).map_err(|_| ())?; let payload = project_payload_runtime(payload, &state.runtime_id); let entry = state.selectors.get_mut(&selector).ok_or(())?; - if let Some(subject) = event_subject(&payload) { - let revision = entry.revisions.entry(subject).or_insert(0); - if subject_revision <= *revision { - return Ok(()); - } - *revision = subject_revision; - } - if let Some((snapshot_revision, snapshot)) = entry.snapshot.as_mut() { - *snapshot_revision = snapshot_revision.saturating_add(1); + if let Some(snapshot) = entry.snapshot.as_mut() { apply_event_to_cached_snapshot(snapshot, &payload); } broadcast( &mut entry.downstreams, - BrokerSubscriptionEvent::Event { - connection_generation: state.generation, - subject_revision, - payload, - }, + BrokerSubscriptionEvent::Event { payload }, ); } SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed { @@ -785,14 +892,9 @@ async fn handle_frame( let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) { entry.upstream_id = None; entry.snapshot = None; - entry.revisions.clear(); broadcast( &mut entry.downstreams, - BrokerSubscriptionEvent::Closed { - connection_generation: state.generation, - code, - message, - }, + BrokerSubscriptionEvent::Closed { code, message }, ); !entry.downstreams.is_empty() } else { @@ -822,10 +924,9 @@ fn close_all(state: &mut State, replacement: u64) { broadcast( &mut entry.downstreams, BrokerSubscriptionEvent::Closed { - connection_generation: state.generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!( - "Runtime connection generation {} was fenced by generation {replacement}", + "Runtime registration {} was replaced by {replacement}", state.generation ), }, @@ -846,51 +947,6 @@ fn broadcast( downstreams.retain(|id, _| !closed.contains(id)); } -fn snapshot_revisions(snapshot: &SubscriptionSnapshot) -> HashMap { - match snapshot { - SubscriptionSnapshot::Workers { workers } => workers - .iter() - .map(|worker| { - ( - format!( - "{}:{}", - worker.runtime_id.as_deref().unwrap_or_default(), - worker.worker_id - ), - worker.subject_revision, - ) - }) - .collect(), - SubscriptionSnapshot::WorkerProtocol { worker_id, .. } => { - HashMap::from([(worker_id.to_string(), 0)]) - } - SubscriptionSnapshot::WorkspaceWorkdirs { .. } => HashMap::new(), - } -} -fn event_subject(payload: &SubscriptionEventPayload) -> Option { - Some(match payload { - SubscriptionEventPayload::WorkerUpserted { worker } => format!( - "{}:{}", - worker.runtime_id.as_deref().unwrap_or_default(), - worker.worker_id - ), - SubscriptionEventPayload::WorkerRemoved { - worker_id, - runtime_id, - } => format!( - "{}:{}", - runtime_id.as_deref().unwrap_or_default(), - worker_id - ), - SubscriptionEventPayload::WorkerProtocol { worker_id, .. } => worker_id.to_string(), - SubscriptionEventPayload::WorkdirUpserted { workdir } => { - workdir.working_directory_id.to_string() - } - SubscriptionEventPayload::WorkdirRemoved { - working_directory_id, - } => working_directory_id.to_string(), - }) -} async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> { frame.validate().map_err(|_| ())?; socket @@ -979,7 +1035,6 @@ fn runtime_token( } fn update_status(status: &RwLock, state: &State, connected: bool) { *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { - connection_generation: state.generation, connected, desired_selectors: state .selectors diff --git a/crates/workspace-server/src/runtime_subscription_tests.rs b/crates/workspace-server/src/runtime_subscription_tests.rs index 07529ec24..302c0db3e 100644 --- a/crates/workspace-server/src/runtime_subscription_tests.rs +++ b/crates/workspace-server/src/runtime_subscription_tests.rs @@ -264,16 +264,9 @@ async fn replacing_runtime_registration_fences_the_old_generation() { assert!(second_generation > first_generation); assert!(matches!( next_event(&mut old).await, - BrokerSubscriptionEvent::Closed { - connection_generation, - .. - } if connection_generation == first_generation + BrokerSubscriptionEvent::Closed { .. } )); - let status = wait_for_status(&broker, "runtime-test", |status| { - status.connection_generation == second_generation && status.connected - }) - .await; - assert_eq!(status.connection_generation, second_generation); + wait_for_status(&broker, "runtime-test", |status| status.connected).await; server.abort(); } @@ -430,6 +423,75 @@ async fn embedded_runtime_uses_in_process_subscription_source() { server.abort(); } +#[tokio::test] +async fn embedded_downstream_overflow_recovers_through_a_fresh_snapshot() { + let (runtime, _config, server) = fixture().await; + let worker = runtime.list_workers().unwrap().remove(0); + let broker = RuntimeSubscriptionBroker::new("local"); + broker.register_embedded_runtime("embedded-worker-runtime", runtime.clone()); + let mut subscription = broker + .subscribe( + "embedded-worker-runtime", + EventSubscriptionSelector::RuntimeWorkers, + ) + .unwrap(); + assert!(matches!( + next_snapshot(&mut subscription).await, + BrokerSubscriptionEvent::Snapshot { .. } + )); + + let final_command_id = (DOWNSTREAM_QUEUE_CAPACITY as u64) * 3; + for command_id in 1..=final_command_id { + let mut snapshot = worker.worker_state.clone().unwrap(); + snapshot.last_command_id = command_id; + runtime + .observe_worker_event( + &worker.worker_ref, + protocol::Event::WorkerState { snapshot }, + ) + .unwrap(); + } + + let mut saw_recovery_snapshot = false; + let mut converged = false; + tokio::time::timeout(Duration::from_secs(5), async { + while !converged { + match subscription + .recv() + .await + .expect("broker subscription closed") + { + BrokerSubscriptionEvent::Snapshot { + snapshot: SubscriptionSnapshot::Workers { workers }, + } => { + saw_recovery_snapshot = true; + converged = workers.iter().any(|candidate| { + candidate.worker_id.as_str() == worker.worker_id.to_string() + && candidate + .worker_state + .as_ref() + .is_some_and(|state| state.last_command_id == final_command_id) + }); + } + BrokerSubscriptionEvent::Event { + payload: SubscriptionEventPayload::WorkerUpserted { worker: candidate }, + } if saw_recovery_snapshot => { + converged = candidate.worker_id.as_str() == worker.worker_id.to_string() + && candidate + .worker_state + .as_ref() + .is_some_and(|state| state.last_command_id == final_command_id); + } + _ => {} + } + } + }) + .await + .expect("overflow recovery did not converge"); + assert!(saw_recovery_snapshot); + server.abort(); +} + #[tokio::test] async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() { let config = RemoteRuntimeConfig::new( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ba610e53c..a5504d863 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1501,10 +1501,11 @@ impl WorkerRemovalService { /// and WorkerRemove use the retention commit below; compensation preserves /// its pre-create semantics while sharing the atomic catalog contract. fn commit_catalog_removal(&self, target: &RuntimeWorkerRef) -> crate::Result<()> { - let commit = self - .store - .delete_worker_registry(&self.workspace_id, target)?; - self.worker_projection.publish_commit(commit) + self.worker_projection.publish_ordered(|| { + self.store + .delete_worker_registry(&self.workspace_id, target) + .map(|commit| ((), commit)) + }) } async fn execute_cleanup_removal(&self, candidate: &CleanupWorkerCandidate) -> ApiResult<()> { @@ -1631,18 +1632,17 @@ impl WorkerRemovalService { )); } }; - match self.store.commit_worker_removal( - &self.workspace_id, - &prepared.plan.operation_id, - &prepared.plan.input_fingerprint, - &result, - ) { - Ok(commit) => { - self.worker_projection - .publish_commit(commit.catalog) - .map_err(|error| error.to_string())?; - Ok(worker_remove_success_response(target)) - } + match self.worker_projection.publish_ordered(|| { + self.store + .commit_worker_removal( + &self.workspace_id, + &prepared.plan.operation_id, + &prepared.plan.input_fingerprint, + &result, + ) + .map(|commit| ((), commit.catalog)) + }) { + Ok(()) => Ok(worker_remove_success_response(target)), Err(error) => Ok(worker_retention_error_response(error)), } } @@ -1764,12 +1764,12 @@ impl WorkerRemovalService { .map_err(|_| "Worker removal recovery authority is unavailable".to_string())?; if let Some(prepared) = prepared { if prepared.plan.state == crate::retention::WorkerRemovalPlanState::Succeeded { - let commit = self - .store - .recover_succeeded_worker_removal_catalog(&self.workspace_id, target) - .map_err(|error| error.to_string())?; self.worker_projection - .publish_commit(commit) + .publish_ordered(|| { + self.store + .recover_succeeded_worker_removal_catalog(&self.workspace_id, target) + .map(|commit| ((), commit)) + }) .map_err(|error| error.to_string())?; return Ok(worker_remove_success_response(target)); } @@ -1947,17 +1947,17 @@ impl WorkerRemovalService { )); } }; - match self.store.commit_worker_removal( - &self.workspace_id, - &plan.operation_id, - &plan.input_fingerprint, - &retention_result, - ) { - Ok(commit) => { - self.worker_projection - .publish_commit(commit.catalog) - .map_err(|error| error.to_string())?; - } + match self.worker_projection.publish_ordered(|| { + self.store + .commit_worker_removal( + &self.workspace_id, + &plan.operation_id, + &plan.input_fingerprint, + &retention_result, + ) + .map(|commit| ((), commit.catalog)) + }) { + Ok(()) => {} Err(error) => { let _ = self.store.fail_worker_removal( &self.workspace_id, @@ -3454,17 +3454,19 @@ impl WorkspaceApi { }; let bound_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); let runtime_run_id = crate::backend_job::runtime_run_id(&reservation.attempt.attempt_id); - let (commit, newly_bound) = self.store.bind_backend_job_attempt_worker( - &self.config.workspace_id, - &request.job_id, - &reservation.attempt.attempt_id, - &worker.worker, - Some(&runtime_run_id), - &bound_at, - )?; - if newly_bound { - self.worker_projection.publish_commit(commit)?; - } else { + let newly_bound = self.worker_projection.publish_ordered(|| { + self.store + .bind_backend_job_attempt_worker( + &self.config.workspace_id, + &request.job_id, + &reservation.attempt.attempt_id, + &worker.worker, + Some(&runtime_run_id), + &bound_at, + ) + .map(|(commit, newly_bound)| (newly_bound, commit)) + })?; + if !newly_bound { let job = self .store .get_backend_job(&self.config.workspace_id, &request.job_id)? @@ -19435,10 +19437,11 @@ async fn scoped_start_workspace_orchestrator( deleted.diagnostics, )); } - let removal = api - .store - .delete_worker_registry(&api.config.workspace_id, &existing.worker)?; - api.worker_projection.publish_commit(removal)?; + api.worker_projection.publish_ordered(|| { + api.store + .delete_worker_registry(&api.config.workspace_id, &existing.worker) + .map(|commit| ((), commit)) + })?; disposition = "recreated"; } @@ -21768,22 +21771,23 @@ async fn set_worker_retention( } } let retention_state = if pinned { "pinned" } else { "normal" }; - let commit = api.store.update_worker_retention( - &api.config.workspace_id, - &worker_ref, - retention_state, - now_registry_timestamp().as_str(), - )?; - if commit.changes.is_empty() { + let changed = api.worker_projection.publish_ordered(|| { + api.store + .update_worker_retention( + &api.config.workspace_id, + &worker_ref, + retention_state, + now_registry_timestamp().as_str(), + ) + .map(|commit| (!commit.changes.is_empty(), commit)) + })?; + if !changed { return Err(cleanup_api_error( runtime_id.as_str(), "workspace_worker_retention_unknown_worker", "Worker is not known to the Backend registry", )); } - api.worker_projection - .publish_commit(commit) - .map_err(ApiError::from)?; Ok(Json(WorkerRetentionResponse { workspace_id: api.config.workspace_id, runtime_id: worker_ref.runtime_id, @@ -26680,7 +26684,7 @@ fn project_observed_workspace_workers( fn workers_response(api: WorkspaceApi) -> ApiResult { let limit = api.config.max_records.min(200); - let (_, projections) = api + let projections = api .store .worker_registry_projection_snapshot(&api.config.workspace_id, limit)?; let workdir_records = api @@ -27478,9 +27482,12 @@ fn record_worker_summary( created_at: timestamp.clone(), updated_at: timestamp, }; - let catalog_commit = api.store.upsert_worker_registry(&record)?; api.worker_projection - .publish_commit(catalog_commit) + .publish_ordered(|| { + api.store + .upsert_worker_registry(&record) + .map(|commit| ((), commit)) + }) .map_err(ApiError::from)?; if let Ok(worker_id) = protocol::subscription::SubscriptionWorkerId::new(worker.worker.worker_id.clone()) @@ -27496,7 +27503,6 @@ fn record_worker_summary( runtime_id: Some(worker.worker.runtime_id.clone()), resource_key: None, availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, - subject_revision: 0, worker_state: worker.worker_state.clone(), state, has_running_internal_workers: false, @@ -29427,7 +29433,6 @@ mod tests { runtime_id: Some("runtime-a".to_string()), resource_key: Some("W-1".to_string()), availability, - subject_revision: 4, worker_state: Some(protocol::WorkerStateSnapshot::initial()), state: protocol::subscription::SubscriptionWorkerState::Idle, has_running_internal_workers: false, @@ -29438,10 +29443,6 @@ mod tests { workdir_attachments: Vec::new(), }, availability, - connection_generation: 2, - subject_revision: 4, - snapshot_revision: 7, - projection_revision: 9, observed_at: "2026-09-24T00:00:01Z".to_string(), }), job: None, @@ -33018,7 +33019,6 @@ mod tests { runtime_id: Some(EMBEDDED_WORKER_RUNTIME_ID.to_string()), resource_key: None, availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, - subject_revision: 1, worker_state: None, state: protocol::subscription::SubscriptionWorkerState::Paused, has_running_internal_workers: false, @@ -44053,7 +44053,8 @@ mod tests { let summary = api.runtime.worker(&target).unwrap(); sync_worker_observation(&api, &summary).unwrap(); seed_worker_control_grant(&api, &source, &target, "embedded-valid-proof"); - let mut subscriber = api.worker_projection.subscribe(); + let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap(); + let _ = subscriber.take_snapshot(); let response = WorkerRemovalService::new(&api) .execute_async( @@ -44086,7 +44087,7 @@ mod tests { .unwrap() .is_none() ); - let removal = tokio::time::timeout(std::time::Duration::from_secs(1), async { + tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { let event = subscriber.recv().await.expect("projection event"); if event.changes.iter().any(|change| { @@ -44098,7 +44099,6 @@ mod tests { }) .await .expect("WorkerRemove catalog removal broadcast"); - assert!(removal.revision > 0); } #[tokio::test] @@ -44135,7 +44135,8 @@ mod tests { let worker = spawned.worker.unwrap().worker; let summary = api.runtime.worker(&worker).unwrap(); sync_worker_observation(&api, &summary).unwrap(); - let mut subscriber = api.worker_projection.subscribe(); + let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap(); + let _ = subscriber.take_snapshot(); let diagnostics = compensate_failed_worker_spawn( &api, &summary, @@ -44151,7 +44152,7 @@ mod tests { .unwrap() .is_none() ); - let removal = tokio::time::timeout(std::time::Duration::from_secs(1), async { + tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { let event = subscriber.recv().await.expect("projection event"); if event.changes.iter().any(|change| { @@ -44163,7 +44164,6 @@ mod tests { }) .await .expect("spawn compensation catalog removal broadcast"); - assert!(removal.revision > 0); sync_worker_observation(&api, &summary).unwrap(); assert!( api.store @@ -46005,7 +46005,8 @@ mod tests { .expect("singleton Worker cleanup candidate") .clone(); assert_eq!(candidate.blocking_reason, None); - let mut subscriber = api.worker_projection.subscribe(); + let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap(); + let _ = subscriber.take_snapshot(); WorkerRemovalService::new(&api) .execute_cleanup_removal(&candidate) @@ -46129,7 +46130,8 @@ mod tests { .iter() .any(|item| item.worker_id.as_str() == worker_id) ); - let mut subscriber = api.worker_projection.subscribe(); + let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap(); + let _ = subscriber.take_snapshot(); let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID) .unwrap_or_else(|error| panic!("cleanup plan: {}", error.error)); let candidate = plan @@ -46173,20 +46175,18 @@ mod tests { event.changes.as_slice(), [crate::store::WorkerCatalogChange::Removed(removed)] if removed == &worker )); - let acting_revision = tokio::time::timeout( + tokio::time::timeout( std::time::Duration::from_secs(1), next_workspace_worker_removal(&mut acting_socket, worker_id.as_str()), ) .await .expect("acting Workspace Worker subscription removal"); - let observing_revision = tokio::time::timeout( + tokio::time::timeout( std::time::Duration::from_secs(1), next_workspace_worker_removal(&mut observing_socket, worker_id.as_str()), ) .await .expect("independent Workspace Worker subscription removal"); - assert_eq!(acting_revision, event.revision); - assert_eq!(observing_revision, event.revision); assert!( api.store .get_worker_registry(&api.config.workspace_id, &worker) @@ -46202,11 +46202,10 @@ mod tests { recovered.plan.state, crate::retention::WorkerRemovalPlanState::Succeeded ); - let (snapshot_revision, snapshot) = api + let snapshot = api .store .worker_registry_projection_snapshot(&api.config.workspace_id, 100) .unwrap(); - assert_eq!(snapshot_revision, event.revision); assert!( snapshot .iter() @@ -46228,8 +46227,8 @@ mod tests { .all(|item| item.worker_id.as_str() != worker_id) ); - // A retry replays the durable fence at the same revision, healing an - // in-process broadcast omitted after the first DB commit. + // A retry replays the durable fence, healing an in-process broadcast + // omitted after the first DB commit. WorkerRemovalService::new(&api) .commit_catalog_removal(&worker) .unwrap(); @@ -46237,7 +46236,6 @@ mod tests { .await .expect("catalog removal replay") .expect("projection replay"); - assert_eq!(replay.revision, event.revision); assert!(matches!( replay.changes.as_slice(), [crate::store::WorkerCatalogChange::Removed(removed)] if removed == &worker @@ -49669,10 +49667,7 @@ mod tests { } } - async fn next_workspace_worker_removal( - socket: &mut TestWebSocket, - expected_worker_id: &str, - ) -> u64 { + async fn next_workspace_worker_removal(socket: &mut TestWebSocket, expected_worker_id: &str) { loop { let Message::Text(text) = socket.next().await.unwrap().unwrap() else { continue; @@ -49681,7 +49676,6 @@ mod tests { serde_json::from_str(text.as_str()).unwrap(); if let protocol::subscription::SubscriptionFramePayload::Event( protocol::subscription::SubscriptionEvent::Event { - subject_revision, payload: protocol::subscription::SubscriptionEventPayload::WorkerRemoved { worker_id, @@ -49692,7 +49686,7 @@ mod tests { ) = frame.payload && worker_id.as_str() == expected_worker_id { - return subject_revision; + return; } } } @@ -49812,7 +49806,7 @@ mod tests { .unwrap(); assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); let worker_id = spawned.worker.unwrap().worker.worker_id; - let (_, projections) = api + let projections = api .store .worker_registry_projection_snapshot(TEST_WORKSPACE_ID, 10) .unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 88c87cd6c..4dfe1198f 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 73; +const LATEST_SCHEMA_VERSION: i64 = 74; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -68,6 +68,8 @@ const BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME: &str = "durable bounded Backend Job dispatch queue"; const WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME: &str = "durable Workspace Worker singleton ownership"; +const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str = + "ordered Worker projection subscriptions without revision reconciliation"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -230,6 +232,11 @@ const MIGRATIONS: &[Migration] = &[ name: WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME, apply: migrate_worker_singleton_ownership_v72_to_v73, }, + Migration { + version: 74, + name: ORDERED_WORKER_PROJECTION_MIGRATION_NAME, + apply: migrate_ordered_worker_projection_v73_to_v74, + }, ]; #[derive(Clone, Copy)] @@ -638,17 +645,10 @@ pub struct WorkerRegistryRecord { } /// Runtime-backed observation joined to the durable Worker catalog. -/// -/// The Runtime payload remains intact so protocol projection is lossless. Ordering -/// metadata is persisted separately to reject stale reconnect snapshots and events. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerRegistryObservationRecord { pub worker: SubscriptionWorker, pub availability: SubscriptionWorkerAvailability, - pub connection_generation: u64, - pub subject_revision: u64, - pub snapshot_revision: u64, - pub projection_revision: u64, pub observed_at: String, } @@ -666,14 +666,9 @@ pub enum WorkerCatalogChange { Removed(RuntimeWorkerRef), } -/// A catalog mutation and the exact projection payload committed at its revision. -/// -/// Callers may broadcast this value, but must never re-read catalog rows to infer -/// what happened: a later catalog mutation could otherwise be paired with an -/// older revision. +/// A catalog mutation and the exact projection payload captured in its transaction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerRegistryProjectionCommit { - pub revision: u64, pub changes: Vec, } @@ -1623,7 +1618,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, limit: usize, - ) -> Result<(u64, Vec)>; + ) -> Result>; fn worker_registry_projection( &self, workspace_id: &str, @@ -1633,8 +1628,6 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, - snapshot_revision: u64, workers: &[SubscriptionWorker], observed_at: &str, ) -> Result; @@ -1642,7 +1635,6 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, worker: &SubscriptionWorker, observed_at: &str, ) -> Result; @@ -1650,9 +1642,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, worker_id: Option<&str>, - subject_revision: Option, observed_at: &str, ) -> Result; fn commit_worker_registry_projection_refresh( @@ -6383,7 +6373,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } let commit = WorkerRegistryProjectionCommit { - revision: current_worker_projection_revision(&tx, workspace_id)?, changes: Vec::new(), }; tx.commit()?; @@ -6404,10 +6393,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } let changed_workers = vec![worker.clone()]; - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; let changes = worker_catalog_upsert_changes(&tx, workspace_id, &changed_workers)?; tx.commit()?; - Ok((WorkerRegistryProjectionCommit { revision, changes }, true)) + Ok((WorkerRegistryProjectionCommit { changes }, true)) }) } @@ -6775,7 +6763,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; if removal_blocks_upsert { return Ok(WorkerRegistryProjectionCommit { - revision: current_worker_projection_revision(&tx, &record.workspace_id)?, changes: Vec::new(), }); } @@ -6833,15 +6820,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } else { vec![record.worker.clone()] }; - let revision = - commit_worker_projection_changes(&tx, &record.workspace_id, &changed_workers)?; let changes = worker_catalog_upsert_changes( &tx, &record.workspace_id, changed_workers.as_slice(), )?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -6930,20 +6915,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, limit: usize, - ) -> Result<(u64, Vec)> { + ) -> Result> { self.with_conn(|conn| { - let revision = current_worker_projection_revision(conn, workspace_id)?; let mut statement = conn.prepare( "SELECT runtime_id, worker_id FROM worker_registry WHERE workspace_id = ?1 ORDER BY updated_at DESC LIMIT ?2", )?; - let identities = statement.query_map(params![workspace_id, limit as i64], |row| { - Ok(RuntimeWorkerRef { runtime_id: row.get(0)?, worker_id: row.get(1)? }) - })?.collect::, _>>()?; - let records = identities.iter() + let identities = statement + .query_map(params![workspace_id, limit as i64], |row| { + Ok(RuntimeWorkerRef { + runtime_id: row.get(0)?, + worker_id: row.get(1)?, + }) + })? + .collect::, _>>()?; + identities + .iter() .map(|worker| read_worker_registry_projection(conn, workspace_id, worker)) - .collect::>>()? - .into_iter().flatten().collect(); - Ok((revision, records)) + .collect::>>() + .map(|records| records.into_iter().flatten().collect()) }) } @@ -6959,33 +6948,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, - snapshot_revision: u64, workers: &[SubscriptionWorker], observed_at: &str, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let (current_generation, current_snapshot) = - worker_projection_cursor(&tx, workspace_id, runtime_id)?; - if connection_generation < current_generation - || (connection_generation == current_generation - && snapshot_revision < current_snapshot) - { - let revision = current_worker_projection_revision(&tx, workspace_id)?; - tx.commit()?; - return Ok(WorkerRegistryProjectionCommit { - revision, - changes: Vec::new(), - }); - } - upsert_worker_projection_cursor( - &tx, - workspace_id, - runtime_id, - connection_generation, - snapshot_revision, - )?; let catalog = worker_registry_identities_for_runtime(&tx, workspace_id, runtime_id)?; let runtime_workers = workers .iter() @@ -7014,32 +6981,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &tx, workspace_id, runtime_id, - connection_generation, - snapshot_revision, worker, SubscriptionWorkerAvailability::Observed, observed_at, )? } else { - set_worker_observation_unavailable( - &tx, - workspace_id, - &identity, - connection_generation, - None, - snapshot_revision, - observed_at, - )? + set_worker_observation_unavailable(&tx, workspace_id, &identity, observed_at)? }; if changed { changed_workers.push(identity); } } - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; - let changes = - worker_catalog_upsert_changes(&tx, workspace_id, changed_workers.as_slice())?; + let changes = worker_catalog_upsert_changes(&tx, workspace_id, &changed_workers)?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -7047,16 +7002,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, worker: &SubscriptionWorker, observed_at: &str, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let identity = RuntimeWorkerRef { - runtime_id: runtime_id.to_string(), - worker_id: worker.worker_id.to_string(), - }; + let identity = RuntimeWorkerRef::new(runtime_id, worker.worker_id.to_string()); if !worker_registry_identity_exists(&tx, workspace_id, &identity)? { insert_worker_projection_orphan_diagnostic( &tx, @@ -7066,10 +7017,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "runtime_worker_without_backend_catalog", observed_at, )?; - let revision = current_worker_projection_revision(&tx, workspace_id)?; tx.commit()?; return Ok(WorkerRegistryProjectionCommit { - revision, changes: Vec::new(), }); } @@ -7077,18 +7026,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &tx, workspace_id, runtime_id, - connection_generation, - 0, worker, SubscriptionWorkerAvailability::Observed, observed_at, )?; let changed_workers = if changed { vec![identity] } else { Vec::new() }; - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; - let changes = - worker_catalog_upsert_changes(&tx, workspace_id, changed_workers.as_slice())?; + let changes = worker_catalog_upsert_changes(&tx, workspace_id, &changed_workers)?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -7096,18 +7041,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &self, workspace_id: &str, runtime_id: &str, - connection_generation: u64, worker_id: Option<&str>, - subject_revision: Option, observed_at: &str, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let identities = if let Some(worker_id) = worker_id { - vec![RuntimeWorkerRef { - runtime_id: runtime_id.to_string(), - worker_id: worker_id.to_string(), - }] + vec![RuntimeWorkerRef::new(runtime_id, worker_id)] } else { worker_registry_identities_for_runtime(&tx, workspace_id, runtime_id)? }; @@ -7124,23 +7064,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; continue; } - if set_worker_observation_unavailable( - &tx, - workspace_id, - &identity, - connection_generation, - subject_revision, - 0, - observed_at, - )? { + if set_worker_observation_unavailable(&tx, workspace_id, &identity, observed_at)? { changed_workers.push(identity); } } - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; - let changes = - worker_catalog_upsert_changes(&tx, workspace_id, changed_workers.as_slice())?; + let changes = worker_catalog_upsert_changes(&tx, workspace_id, &changed_workers)?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -7156,11 +7086,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } else { Vec::new() }; - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; let changes = worker_catalog_upsert_changes(&tx, workspace_id, changed_workers.as_slice())?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -7196,11 +7125,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } else { vec![worker.clone()] }; - let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; let changes = worker_catalog_upsert_changes(&tx, workspace_id, changed_workers.as_slice())?; tx.commit()?; - Ok(WorkerRegistryProjectionCommit { revision, changes }) + Ok(WorkerRegistryProjectionCommit { changes }) }) } @@ -7235,21 +7163,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ], )?; commit_worker_catalog_removal(&tx, workspace_id, worker)? - } else if let Some(revision) = tx + } else if tx .query_row( - "SELECT projection_revision FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + "SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", params![workspace_id, worker.runtime_id, worker.worker_id], - |row| row.get::<_, i64>(0), + |_| Ok(()), ) .optional()? + .is_some() { WorkerRegistryProjectionCommit { - revision: revision.max(0) as u64, changes: vec![WorkerCatalogChange::Removed(worker.clone())], } } else { WorkerRegistryProjectionCommit { - revision: current_worker_projection_revision(&tx, workspace_id)?, changes: Vec::new(), } }; @@ -10539,72 +10466,57 @@ fn read_worker_registry_projection( .optional()?; let observation = conn .query_row( - "SELECT availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + "SELECT availability, worker_json, observed_at FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", params![workspace_id, worker.runtime_id, worker.worker_id], |row| { - let availability: String = row.get(0)?; - let worker_json: Option = row.get(1)?; - Ok((availability, worker_json, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?, row.get::<_, i64>(4)?, row.get::<_, i64>(5)?, row.get::<_, String>(6)?)) + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + )) }, ) .optional()?; let observation = observation - .map( - |( - availability, - worker_json, - generation, - subject_revision, - snapshot_revision, - projection_revision, - observed_at, - )| - -> Result<_> { - let availability = match availability.as_str() { - "observed" => SubscriptionWorkerAvailability::Observed, - "unavailable" => SubscriptionWorkerAvailability::Unavailable, - other => { - return Err(Error::InvalidInput(format!( - "invalid Worker observation availability `{other}`" - ))); - } - }; - let mut observed_worker: SubscriptionWorker = if let Some(worker_json) = worker_json - { - serde_json::from_str(&worker_json).map_err(|error| { - Error::InvalidInput(format!("invalid stored Worker observation: {error}")) - })? - } else { - SubscriptionWorker { - worker_id: SubscriptionWorkerId::new(worker.worker_id.to_string()) - .map_err(|error| Error::InvalidInput(error.to_string()))?, - runtime_id: Some(worker.runtime_id.to_string()), - resource_key: resource_key.clone(), - availability, - subject_revision: subject_revision.max(0) as u64, - state: protocol::subscription::SubscriptionWorkerState::Stopped, - worker_state: None, - has_running_internal_workers: false, - workspace_id: Some(workspace_id.to_string()), - display_name: Some(registry.display_name.clone()), - profile: registry.profile.clone(), - job: None, - workdir_attachments: Vec::new(), - } - }; - observed_worker.availability = availability; - observed_worker.resource_key = resource_key.clone(); - Ok(WorkerRegistryObservationRecord { - worker: observed_worker, + .map(|(availability, worker_json, observed_at)| -> Result<_> { + let availability = match availability.as_str() { + "observed" => SubscriptionWorkerAvailability::Observed, + "unavailable" => SubscriptionWorkerAvailability::Unavailable, + other => { + return Err(Error::InvalidInput(format!( + "invalid Worker observation availability `{other}`" + ))); + } + }; + let mut observed_worker: SubscriptionWorker = if let Some(worker_json) = worker_json { + serde_json::from_str(&worker_json).map_err(|error| { + Error::InvalidInput(format!("invalid stored Worker observation: {error}")) + })? + } else { + SubscriptionWorker { + worker_id: SubscriptionWorkerId::new(worker.worker_id.to_string()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + runtime_id: Some(worker.runtime_id.to_string()), + resource_key: resource_key.clone(), availability, - connection_generation: generation.max(0) as u64, - subject_revision: subject_revision.max(0) as u64, - snapshot_revision: snapshot_revision.max(0) as u64, - projection_revision: projection_revision.max(0) as u64, - observed_at, - }) - }, - ) + state: protocol::subscription::SubscriptionWorkerState::Stopped, + worker_state: None, + has_running_internal_workers: false, + workspace_id: Some(workspace_id.to_string()), + display_name: Some(registry.display_name.clone()), + profile: registry.profile.clone(), + job: None, + workdir_attachments: Vec::new(), + } + }; + observed_worker.availability = availability; + observed_worker.resource_key = resource_key.clone(); + Ok(WorkerRegistryObservationRecord { + worker: observed_worker, + availability, + observed_at, + }) + }) .transpose()?; let job = conn .query_row( @@ -10630,44 +10542,6 @@ fn read_worker_registry_projection( })) } -fn current_worker_projection_revision(conn: &Connection, workspace_id: &str) -> Result { - Ok(conn - .query_row( - "SELECT revision FROM worker_registry_projection_revisions WHERE workspace_id = ?1", - params![workspace_id], - |row| row.get::<_, i64>(0), - ) - .optional()? - .unwrap_or(0) - .max(0) as u64) -} - -fn worker_projection_cursor( - conn: &Connection, - workspace_id: &str, - runtime_id: &str, -) -> Result<(u64, u64)> { - Ok(conn.query_row( - "SELECT connection_generation, snapshot_revision FROM worker_registry_projection_cursors WHERE workspace_id = ?1 AND runtime_id = ?2", - params![workspace_id, runtime_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), - ).optional()?.map(|(generation, revision)| (generation.max(0) as u64, revision.max(0) as u64)).unwrap_or((0, 0))) -} - -fn upsert_worker_projection_cursor( - conn: &Connection, - workspace_id: &str, - runtime_id: &str, - generation: u64, - snapshot_revision: u64, -) -> Result<()> { - conn.execute( - "INSERT INTO worker_registry_projection_cursors (workspace_id, runtime_id, connection_generation, snapshot_revision) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET connection_generation = excluded.connection_generation, snapshot_revision = excluded.snapshot_revision", - params![workspace_id, runtime_id, generation as i64, snapshot_revision as i64], - )?; - Ok(()) -} - fn worker_registry_identities_for_runtime( conn: &Connection, workspace_id: &str, @@ -10715,30 +10589,17 @@ fn upsert_worker_observation( conn: &Connection, workspace_id: &str, runtime_id: &str, - connection_generation: u64, - snapshot_revision: u64, worker: &SubscriptionWorker, availability: SubscriptionWorkerAvailability, observed_at: &str, ) -> Result { - let existing = conn.query_row( - "SELECT availability, worker_json, connection_generation, subject_revision FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", - params![workspace_id, runtime_id, worker.worker_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?)), - ).optional()?; - if let Some((old_availability, _, generation, revision)) = existing.as_ref() { - let generation = (*generation).max(0) as u64; - let revision = (*revision).max(0) as u64; - let restores_unavailable = old_availability == "unavailable" - && availability == SubscriptionWorkerAvailability::Observed; - if connection_generation < generation - || (connection_generation == generation - && (worker.subject_revision < revision - || (worker.subject_revision == revision && !restores_unavailable))) - { - return Ok(false); - } - } + let existing = conn + .query_row( + "SELECT availability, worker_json FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, runtime_id, worker.worker_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()?; let mut persisted_worker = worker.clone(); persisted_worker.runtime_id = Some(runtime_id.to_string()); persisted_worker.availability = availability; @@ -10751,13 +10612,13 @@ fn upsert_worker_observation( }; let changed = existing .as_ref() - .is_none_or(|(old_availability, old_json, _, _)| { + .is_none_or(|(old_availability, old_json)| { old_availability != availability_text || old_json.as_deref() != Some(worker_json.as_str()) }); conn.execute( - "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = excluded.availability, worker_json = excluded.worker_json, connection_generation = excluded.connection_generation, subject_revision = excluded.subject_revision, snapshot_revision = MAX(worker_registry_observations.snapshot_revision, excluded.snapshot_revision), observed_at = excluded.observed_at", - params![workspace_id, runtime_id, worker.worker_id.as_str(), availability_text, worker_json, connection_generation as i64, worker.subject_revision as i64, snapshot_revision as i64, observed_at], + "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, observed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = excluded.availability, worker_json = excluded.worker_json, observed_at = excluded.observed_at", + params![workspace_id, runtime_id, worker.worker_id.as_str(), availability_text, worker_json, observed_at], )?; Ok(changed) } @@ -10766,39 +10627,25 @@ fn set_worker_observation_unavailable( conn: &Connection, workspace_id: &str, worker: &RuntimeWorkerRef, - connection_generation: u64, - subject_revision: Option, - snapshot_revision: u64, observed_at: &str, ) -> Result { - let existing = conn.query_row( - "SELECT availability, connection_generation, subject_revision, worker_json FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", - params![workspace_id, worker.runtime_id, worker.worker_id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option>(3)?)), - ).optional()?; - if let Some((availability, generation, revision, _)) = existing.as_ref() { - let generation = (*generation).max(0) as u64; - let revision = (*revision).max(0) as u64; - if connection_generation < generation - || (connection_generation == generation - && subject_revision.is_some_and(|incoming| incoming <= revision)) - { - return Ok(false); - } - if availability == "unavailable" - && subject_revision.is_none() - && connection_generation == generation - { - return Ok(false); - } + let existing = conn + .query_row( + "SELECT availability, worker_json FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()?; + if existing + .as_ref() + .is_some_and(|(availability, _)| availability == "unavailable") + { + return Ok(false); } - let revision = subject_revision - .or_else(|| existing.as_ref().map(|item| item.2.max(0) as u64)) - .unwrap_or(0); - let worker_json = existing.and_then(|item| item.3); + let worker_json = existing.and_then(|item| item.1); conn.execute( - "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at) VALUES (?1, ?2, ?3, 'unavailable', ?4, ?5, ?6, ?7, 0, ?8) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = 'unavailable', connection_generation = excluded.connection_generation, subject_revision = excluded.subject_revision, snapshot_revision = MAX(worker_registry_observations.snapshot_revision, excluded.snapshot_revision), observed_at = excluded.observed_at", - params![workspace_id, worker.runtime_id, worker.worker_id, worker_json, connection_generation as i64, revision as i64, snapshot_revision as i64, observed_at], + "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, observed_at) VALUES (?1, ?2, ?3, 'unavailable', ?4, ?5) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = 'unavailable', observed_at = excluded.observed_at", + params![workspace_id, worker.runtime_id, worker.worker_id, worker_json, observed_at], )?; Ok(true) } @@ -10832,52 +10679,23 @@ pub(crate) fn commit_worker_catalog_removal( workspace_id: &str, worker: &RuntimeWorkerRef, ) -> Result { - if let Some(revision) = conn + let existing = conn .query_row( - "SELECT projection_revision FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + "SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", params![workspace_id, worker.runtime_id, worker.worker_id], - |row| row.get::<_, i64>(0), + |_| Ok(()), ) .optional()? - { - return Ok(WorkerRegistryProjectionCommit { - revision: revision.max(0) as u64, - changes: vec![WorkerCatalogChange::Removed(worker.clone())], - }); - } - let revision = - commit_worker_projection_changes(conn, workspace_id, std::slice::from_ref(worker))?; - conn.execute( - "INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id, projection_revision) VALUES (?1, ?2, ?3, ?4)", - params![workspace_id, worker.runtime_id, worker.worker_id, revision as i64], - )?; - Ok(WorkerRegistryProjectionCommit { - revision, - changes: vec![WorkerCatalogChange::Removed(worker.clone())], - }) -} - -fn commit_worker_projection_changes( - conn: &Connection, - workspace_id: &str, - changed_workers: &[RuntimeWorkerRef], -) -> Result { - let current = current_worker_projection_revision(conn, workspace_id)?; - if changed_workers.is_empty() { - return Ok(current); - } - let revision = current.saturating_add(1); - conn.execute( - "INSERT INTO worker_registry_projection_revisions (workspace_id, revision) VALUES (?1, ?2) ON CONFLICT(workspace_id) DO UPDATE SET revision = excluded.revision", - params![workspace_id, revision as i64], - )?; - for worker in changed_workers { + .is_some(); + if !existing { conn.execute( - "UPDATE worker_registry_observations SET projection_revision = ?1 WHERE workspace_id = ?2 AND runtime_id = ?3 AND worker_id = ?4", - params![revision as i64, workspace_id, worker.runtime_id, worker.worker_id], + "INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id) VALUES (?1, ?2, ?3)", + params![workspace_id, worker.runtime_id, worker.worker_id], )?; } - Ok(revision) + Ok(WorkerRegistryProjectionCommit { + changes: vec![WorkerCatalogChange::Removed(worker.clone())], + }) } fn read_worker_control_grant_record( @@ -13911,6 +13729,60 @@ fn migrate_worker_singleton_ownership_v72_to_v73(conn: &Connection) -> Result<() Ok(()) } +fn migrate_ordered_worker_projection_v73_to_v74(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 73 { + return Err(Error::Store(format!( + "expected schema version 73 before {ORDERED_WORKER_PROJECTION_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v73; + CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_observations ( + workspace_id, runtime_id, worker_id, availability, worker_json, observed_at + ) + SELECT workspace_id, runtime_id, worker_id, availability, worker_json, observed_at + FROM worker_registry_observations_v73; + DROP TABLE worker_registry_observations_v73; + + DROP TABLE IF EXISTS worker_registry_projection_cursors; + DROP TABLE IF EXISTS worker_registry_projection_revisions; + + ALTER TABLE worker_registry_projection_removals + RENAME TO worker_registry_projection_removals_v73; + CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) + ); + INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id) + SELECT workspace_id, runtime_id, worker_id + FROM worker_registry_projection_removals_v73; + DROP TABLE worker_registry_projection_removals_v73; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![74_i64, ORDERED_WORKER_PROJECTION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -14742,6 +14614,130 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) mod tests { use super::*; + #[test] + fn schema_v74_migrates_historical_worker_projection_rows_without_revision_state() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("workspace.db"); + drop(SqliteWorkspaceStore::open(&path).unwrap()); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch( + r#" + INSERT INTO accounts ( + account_id, kind, handle, display_name, created_at, updated_at + ) VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces ( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + INSERT INTO worker_registry ( + workspace_id, worker_id, runtime_id, display_name, profile, + retention_state, created_at, updated_at + ) VALUES ('workspace-a', 'worker-a', 'runtime-a', 'Worker A', NULL, + 'normal', '1', '1'); + + ALTER TABLE worker_registry_observations + RENAME TO worker_registry_observations_v74; + CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + connection_generation INTEGER NOT NULL, + subject_revision INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + projection_revision INTEGER NOT NULL, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_observations VALUES ( + 'workspace-a', 'runtime-a', 'worker-a', 'observed', '{}', + 8, 13, 21, 34, '2' + ); + DROP TABLE worker_registry_observations_v74; + + CREATE TABLE worker_registry_projection_cursors ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + connection_generation INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id) + ); + INSERT INTO worker_registry_projection_cursors + VALUES ('workspace-a', 'runtime-a', 8, 21); + CREATE TABLE worker_registry_projection_revisions ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL + ); + INSERT INTO worker_registry_projection_revisions VALUES ('workspace-a', 34); + + ALTER TABLE worker_registry_projection_removals + RENAME TO worker_registry_projection_removals_v74; + CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + projection_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) + ); + INSERT INTO worker_registry_projection_removals + VALUES ('workspace-a', 'runtime-a', 'removed-worker', 35); + DROP TABLE worker_registry_projection_removals_v74; + + UPDATE __yoi_schema_migrations SET version = 73; + "#, + ) + .unwrap(); + + migrate_ordered_worker_projection_v73_to_v74(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 74); + assert_eq!( + table_columns(&conn, "worker_registry_observations").unwrap(), + vec![ + "workspace_id", + "runtime_id", + "worker_id", + "availability", + "worker_json", + "observed_at", + ] + ); + assert_eq!( + conn.query_row( + "SELECT availability || ':' || observed_at FROM worker_registry_observations", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "observed:2" + ); + assert!(!table_exists(&conn, "worker_registry_projection_cursors").unwrap()); + assert!(!table_exists(&conn, "worker_registry_projection_revisions").unwrap()); + assert_eq!( + conn.query_row( + "SELECT runtime_id || ':' || worker_id FROM worker_registry_projection_removals", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "runtime-a:removed-worker" + ); + assert_eq!( + conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) + .unwrap(), + "ok" + ); + let foreign_key_failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + } + fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -16182,6 +16178,10 @@ mod tests { version: 73, name: WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 74, + name: ORDERED_WORKER_PROJECTION_MIGRATION_NAME.to_string(), + }, ] ); @@ -16259,6 +16259,7 @@ mod tests { ), (72, BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME.to_string()), (73, WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME.to_string()), + (74, ORDERED_WORKER_PROJECTION_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16545,7 +16546,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73 + 73, 74 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -16554,7 +16555,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 24); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 25); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -19975,7 +19976,6 @@ INSERT INTO worker_registry ( runtime_id: Some("embedded".to_string()), resource_key: None, availability: SubscriptionWorkerAvailability::Observed, - subject_revision: 4, worker_state: None, state: protocol::subscription::SubscriptionWorkerState::Running, has_running_internal_workers: true, @@ -19985,6 +19985,21 @@ INSERT INTO worker_registry ( job: None, workdir_attachments: Vec::new(), }; + let other_catalog = WorkerRegistryRecord { + worker: RuntimeWorkerRef::new("runtime-other", "other"), + display_name: "Other Runtime Worker".to_string(), + ..catalog.clone() + }; + store.upsert_worker_registry(&other_catalog).unwrap(); + let other_observed = SubscriptionWorker { + worker_id: SubscriptionWorkerId::new("other").unwrap(), + runtime_id: Some("runtime-other".to_string()), + display_name: Some("Other Runtime Worker".to_string()), + ..observed.clone() + }; + store + .apply_worker_registry_observation("local-dev", "runtime-other", &other_observed, "2") + .unwrap(); let orphan = SubscriptionWorker { worker_id: SubscriptionWorkerId::new("orphan").unwrap(), ..observed.clone() @@ -19993,8 +20008,6 @@ INSERT INTO worker_registry ( .reconcile_worker_registry_snapshot( "local-dev", "embedded", - 1, - 10, &[observed.clone(), orphan], "2", ) @@ -20003,15 +20016,19 @@ INSERT INTO worker_registry ( first.changes.as_slice(), [WorkerCatalogChange::Upsert(record)] if record.registry.worker == catalog.worker )); - let (_, records) = store + let records = store .worker_registry_projection_snapshot("local-dev", 10) .unwrap(); assert_eq!( records.len(), - 1, + 2, "orphan Runtime rows must not create catalog rows" ); - let observation = records[0].observation.as_ref().unwrap(); + let observation = records + .iter() + .find(|record| record.registry.worker == catalog.worker) + .and_then(|record| record.observation.as_ref()) + .unwrap(); assert_eq!( observation.availability, SubscriptionWorkerAvailability::Observed @@ -20022,7 +20039,7 @@ INSERT INTO worker_registry ( ); let unavailable = store - .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[], "3") + .reconcile_worker_registry_snapshot("local-dev", "embedded", &[], "3") .unwrap(); assert!(matches!( unavailable.changes.as_slice(), @@ -20042,21 +20059,25 @@ INSERT INTO worker_registry ( protocol::subscription::SubscriptionWorkerState::Running, "unavailable must retain the last lifecycle observation rather than becoming stopped" ); + assert_eq!( + store + .worker_registry_projection("local-dev", &other_catalog.worker) + .unwrap() + .unwrap() + .observation + .unwrap() + .availability, + SubscriptionWorkerAvailability::Observed, + "reconciling one Runtime must not alter another Runtime's observations" + ); let duplicate = store - .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[], "4") + .reconcile_worker_registry_snapshot("local-dev", "embedded", &[], "4") .unwrap(); assert!(duplicate.changes.is_empty()); let reconnected = store - .reconcile_worker_registry_snapshot( - "local-dev", - "embedded", - 1, - 11, - &[observed.clone()], - "5", - ) + .reconcile_worker_registry_snapshot("local-dev", "embedded", &[observed.clone()], "5") .unwrap(); assert!(matches!( reconnected.changes.as_slice(), @@ -20069,10 +20090,10 @@ INSERT INTO worker_registry ( assert_eq!( projection.observation.unwrap().availability, SubscriptionWorkerAvailability::Observed, - "an equal-revision reconnect snapshot must restore availability" + "a reconnect snapshot must restore availability" ); let duplicate_reconnect = store - .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[observed], "6") + .reconcile_worker_registry_snapshot("local-dev", "embedded", &[observed], "6") .unwrap(); assert!(duplicate_reconnect.changes.is_empty()); } @@ -20122,7 +20143,6 @@ INSERT INTO worker_registry ( duplicate_removal.changes.as_slice(), [WorkerCatalogChange::Removed(worker)] if worker == &worker_ref )); - assert_eq!(duplicate_removal.revision, first_removal.revision); let stale_upsert = store .upsert_worker_registry(&WorkerRegistryRecord { workspace_id: "local-dev".to_string(), @@ -20139,7 +20159,6 @@ INSERT INTO worker_registry ( }) .unwrap(); assert!(stale_upsert.changes.is_empty()); - assert_eq!(stale_upsert.revision, first_removal.revision); assert!( store .get_worker_registry("local-dev", &worker_ref) @@ -20151,7 +20170,6 @@ INSERT INTO worker_registry ( runtime_id: Some("embedded".to_string()), resource_key: None, availability: SubscriptionWorkerAvailability::Observed, - subject_revision: 99, worker_state: None, state: protocol::subscription::SubscriptionWorkerState::Running, has_running_internal_workers: false, @@ -20162,7 +20180,7 @@ INSERT INTO worker_registry ( workdir_attachments: Vec::new(), }; let commit = store - .apply_worker_registry_observation("local-dev", "embedded", 2, &event, "2") + .apply_worker_registry_observation("local-dev", "embedded", &event, "2") .unwrap(); assert!(commit.changes.is_empty()); assert!( diff --git a/crates/workspace-server/src/worker_projection.rs b/crates/workspace-server/src/worker_projection.rs index 22095f8c0..ed910de04 100644 --- a/crates/workspace-server/src/worker_projection.rs +++ b/crates/workspace-server/src/worker_projection.rs @@ -11,7 +11,10 @@ use tokio::task::JoinHandle; use crate::runtime_subscription::{ BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError, }; -use crate::store::{ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit}; +use crate::store::{ + ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit, + WorkerRegistryProjectionRecord, +}; use worker_runtime::identity::RuntimeWorkerRef; const PROJECTION_EVENT_CAPACITY: usize = 256; @@ -20,21 +23,42 @@ pub type WorkerProjectionChange = WorkerCatalogChange; #[derive(Debug, Clone)] pub struct WorkerProjectionEvent { - pub revision: u64, pub changes: Vec, } +/// An authoritative durable snapshot followed by changes committed after that snapshot. +pub struct WorkerProjectionSubscription { + snapshot: Vec, + receiver: broadcast::Receiver, +} + +impl WorkerProjectionSubscription { + pub fn take_snapshot(&mut self) -> Vec { + std::mem::take(&mut self.snapshot) + } + + pub async fn recv(&mut self) -> Result { + self.receiver.recv().await + } + + #[cfg(test)] + pub fn try_recv(&mut self) -> Result { + self.receiver.try_recv() + } +} + /// Owns the durable Runtime-to-Workspace Worker projection and its independent /// low-frequency publication stream. /// -/// One Runtime subscription is kept alive per registered Runtime even when no -/// browser is connected. Browser subscriptions therefore never become the -/// authority for populating `worker_registry` observations. +/// The ordering gate covers both durable commits and subscription snapshot creation. +/// Every subscription therefore starts with one authoritative snapshot and receives +/// only commits that completed after it, in the same order in which they were stored. pub struct WorkerProjectionService { workspace_id: String, store: Arc, events: broadcast::Sender, - connection_generation_epoch: u64, + ordering: Mutex<()>, + attach_gate: tokio::sync::Mutex<()>, tasks: Mutex>>, } @@ -45,8 +69,8 @@ impl WorkerProjectionService { workspace_id: workspace_id.into(), store, events, - connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0) - as u64, + ordering: Mutex::new(()), + attach_gate: tokio::sync::Mutex::new(()), tasks: Mutex::new(HashMap::new()), }) } @@ -62,8 +86,38 @@ impl WorkerProjectionService { } } - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() + pub fn subscribe_ordered(&self, limit: usize) -> crate::Result { + let _ordering = self + .ordering + .lock() + .expect("Worker projection ordering lock poisoned"); + let receiver = self.events.subscribe(); + let snapshot_limit = limit.saturating_add(1); + let mut snapshot = self + .store + .worker_registry_projection_snapshot(self.workspace_id.as_str(), snapshot_limit)?; + if snapshot.len() > limit { + return Err(crate::Error::Store(format!( + "authoritative Worker projection exceeds the configured snapshot limit of {limit} records" + ))); + } + snapshot.shrink_to_fit(); + Ok(WorkerProjectionSubscription { snapshot, receiver }) + } + + /// Serialize one durable projection mutation with its publication. The closure + /// must return the exact projection payload captured by the same transaction. + pub(crate) fn publish_ordered( + &self, + mutation: impl FnOnce() -> std::result::Result<(T, WorkerRegistryProjectionCommit), E>, + ) -> std::result::Result { + let _ordering = self + .ordering + .lock() + .expect("Worker projection ordering lock poisoned"); + let (result, commit) = mutation()?; + self.publish_commit(commit); + Ok(result) } pub async fn attach_runtime( @@ -71,6 +125,20 @@ impl WorkerProjectionService { broker: &RuntimeSubscriptionBroker, runtime_id: &str, ) -> Result<(), RuntimeSubscriptionBrokerError> { + // Serialize replacement and wait for the old consumer to stop before the new + // lifetime can apply its snapshot. Late notifications can therefore never + // cross a Runtime ownership boundary. + let _attach = self.attach_gate.lock().await; + let previous = self + .tasks + .lock() + .expect("Worker projection task lock poisoned") + .remove(runtime_id); + if let Some(previous) = previous { + previous.abort(); + let _ = previous.await; + } + let mut subscription = broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); @@ -80,12 +148,14 @@ impl WorkerProjectionService { break; }; let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. }); - if let Err(error) = self.apply_broker_event(runtime_id, event).await { + if let Err(error) = self.apply_broker_event(runtime_id, event) { tracing::warn!( runtime_id, error = %error, - "failed to initialize durable Worker projection" + "failed to initialize durable Worker projection; restarting from a fresh snapshot" ); + subscription.restart()?; + continue; } if initialized { break; @@ -99,26 +169,22 @@ impl WorkerProjectionService { let Some(service) = service.upgrade() else { return; }; - if let Err(error) = service - .apply_broker_event(task_runtime_id.as_str(), event) - .await - { + if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) { tracing::warn!( runtime_id = %task_runtime_id, error = %error, - "failed to update durable Worker projection" + "failed to update durable Worker projection; restarting from a fresh snapshot" ); + if subscription.restart().is_err() { + return; + } } } }); - if let Some(previous) = self - .tasks + self.tasks .lock() .expect("Worker projection task lock poisoned") - .insert(runtime_id, task) - { - previous.abort(); - } + .insert(runtime_id, task); Ok(()) } @@ -128,110 +194,96 @@ impl WorkerProjectionService { worker: &protocol::subscription::SubscriptionWorker, observed_at: &str, ) -> crate::Result<()> { - let commit = self.store.apply_worker_registry_observation( - self.workspace_id.as_str(), - runtime_id, - 0, - worker, - observed_at, - )?; - self.publish_commit(commit) + self.publish_ordered(|| { + self.store + .apply_worker_registry_observation( + self.workspace_id.as_str(), + runtime_id, + worker, + observed_at, + ) + .map(|commit| ((), commit)) + }) } pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> { - let commit = self - .store - .commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)?; - self.publish_commit(commit) + self.publish_ordered(|| { + self.store + .commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker) + .map(|commit| ((), commit)) + }) } - async fn apply_broker_event( + fn apply_broker_event( &self, runtime_id: &str, event: BrokerSubscriptionEvent, ) -> crate::Result<()> { let observed_at = Utc::now().to_rfc3339(); - let projection_generation = |connection_generation: u64| { - self.connection_generation_epoch - .saturating_add(connection_generation) - }; - let commit = match event { - BrokerSubscriptionEvent::Snapshot { - connection_generation, - snapshot_revision, - snapshot: SubscriptionSnapshot::Workers { workers }, - } => self.store.reconcile_worker_registry_snapshot( - self.workspace_id.as_str(), - runtime_id, - projection_generation(connection_generation), - snapshot_revision, - workers.as_slice(), - observed_at.as_str(), - )?, - BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()), - BrokerSubscriptionEvent::Event { - connection_generation, - payload: SubscriptionEventPayload::WorkerUpserted { worker }, - .. - } => self.store.apply_worker_registry_observation( - self.workspace_id.as_str(), - runtime_id, - projection_generation(connection_generation), - &worker, - observed_at.as_str(), - )?, - BrokerSubscriptionEvent::Event { - connection_generation, - subject_revision, - payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. }, - } => self.store.mark_worker_registry_observation_unavailable( - self.workspace_id.as_str(), - runtime_id, - projection_generation(connection_generation), - Some(worker_id.as_str()), - Some(subject_revision), - observed_at.as_str(), - )?, - BrokerSubscriptionEvent::Event { .. } => return Ok(()), - BrokerSubscriptionEvent::Disconnected { - connection_generation, - .. - } - | BrokerSubscriptionEvent::Rejected { - connection_generation, - .. - } - | BrokerSubscriptionEvent::Closed { - connection_generation, - .. - } => self.store.mark_worker_registry_observation_unavailable( - self.workspace_id.as_str(), - runtime_id, - projection_generation(connection_generation), - None, - None, - observed_at.as_str(), - )?, - }; - self.publish_commit(commit) + self.publish_ordered(|| { + let commit = match event { + BrokerSubscriptionEvent::Snapshot { + snapshot: SubscriptionSnapshot::Workers { workers }, + } => self.store.reconcile_worker_registry_snapshot( + self.workspace_id.as_str(), + runtime_id, + workers.as_slice(), + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Snapshot { .. } => { + return Ok(( + (), + WorkerRegistryProjectionCommit { + changes: Vec::new(), + }, + )); + } + BrokerSubscriptionEvent::Event { + payload: SubscriptionEventPayload::WorkerUpserted { worker }, + } => self.store.apply_worker_registry_observation( + self.workspace_id.as_str(), + runtime_id, + &worker, + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Event { + payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. }, + } => self.store.mark_worker_registry_observation_unavailable( + self.workspace_id.as_str(), + runtime_id, + Some(worker_id.as_str()), + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Event { .. } => { + return Ok(( + (), + WorkerRegistryProjectionCommit { + changes: Vec::new(), + }, + )); + } + BrokerSubscriptionEvent::Disconnected { .. } + | BrokerSubscriptionEvent::Rejected { .. } + | BrokerSubscriptionEvent::Closed { .. } => { + self.store.mark_worker_registry_observation_unavailable( + self.workspace_id.as_str(), + runtime_id, + None, + observed_at.as_str(), + )? + } + }; + Ok(((), commit)) + }) } - /// Broadcast an already committed catalog payload. Persistence always happens - /// before this call; a process crash therefore closes subscribers, whose next - /// subscription starts from the durable snapshot. Retrying a removal returns - /// the same durable change and can heal an in-process missed broadcast. - pub(crate) fn publish_commit( - &self, - commit: WorkerRegistryProjectionCommit, - ) -> crate::Result<()> { + fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) { if commit.changes.is_empty() { - return Ok(()); + return; } let _ = self.events.send(WorkerProjectionEvent { - revision: commit.revision, changes: commit.changes, }); - Ok(()) } } @@ -240,3 +292,159 @@ impl Drop for WorkerProjectionService { self.shutdown(); } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier, Mutex}; + + use crate::store::{ + AccountRecord, ControlPlaneStore, SqliteWorkspaceStore, WorkerCatalogChange, + WorkerRegistryRecord, WorkspaceRecord, + }; + use worker_runtime::identity::RuntimeWorkerRef; + + use super::WorkerProjectionService; + + const WORKSPACE_ID: &str = "projection-ordering-test"; + + async fn fixture() -> ( + Arc, + Arc, + tempfile::TempDir, + ) { + let temp = tempfile::tempdir().unwrap(); + let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap()); + store + .upsert_account(&AccountRecord { + account_id: "owner".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: WORKSPACE_ID.to_string(), + owner_account_id: "owner".to_string(), + display_name: "Projection ordering".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let service = WorkerProjectionService::new(WORKSPACE_ID, store.clone()); + (store, service, temp) + } + + fn worker(runtime_id: &str, worker_id: &str) -> WorkerRegistryRecord { + WorkerRegistryRecord { + workspace_id: WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + display_name: format!("{runtime_id}-{worker_id}"), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn snapshot_registration_is_atomic_with_concurrent_publication() { + let (store, service, _temp) = fixture().await; + let barrier = Arc::new(Barrier::new(2)); + let subscriber_service = service.clone(); + let subscriber_barrier = barrier.clone(); + let subscriber = std::thread::spawn(move || { + subscriber_barrier.wait(); + subscriber_service.subscribe_ordered(10).unwrap() + }); + let publisher_service = service.clone(); + let publisher_barrier = barrier.clone(); + let publisher_store = store.clone(); + let record = worker("runtime-a", "worker-a"); + let publisher = std::thread::spawn(move || { + publisher_barrier.wait(); + publisher_service + .publish_ordered(|| { + publisher_store + .upsert_worker_registry(&record) + .map(|commit| ((), commit)) + }) + .unwrap(); + }); + + let mut subscription = subscriber.join().unwrap(); + publisher.join().unwrap(); + let in_snapshot = subscription + .take_snapshot() + .iter() + .any(|record| record.registry.worker.worker_id == "worker-a"); + let in_event = match subscription.try_recv() { + Ok(event) => matches!( + event.changes.as_slice(), + [WorkerCatalogChange::Upsert(record)] + if record.registry.worker.worker_id == "worker-a" + ), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => false, + Err(error) => panic!("unexpected projection receive error: {error}"), + }; + assert_ne!( + in_snapshot, in_event, + "the commit must cross the boundary exactly once" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_commits_are_published_in_durable_execution_order() { + let (store, service, _temp) = fixture().await; + let mut subscription = service.subscribe_ordered(10).unwrap(); + assert!(subscription.take_snapshot().is_empty()); + let barrier = Arc::new(Barrier::new(3)); + let durable_order = Arc::new(Mutex::new(Vec::new())); + let mut publishers = Vec::new(); + for worker_id in ["worker-a", "worker-b"] { + let service = service.clone(); + let store = store.clone(); + let barrier = barrier.clone(); + let durable_order = durable_order.clone(); + let record = worker("runtime-a", worker_id); + publishers.push(std::thread::spawn(move || { + barrier.wait(); + service + .publish_ordered(|| { + durable_order + .lock() + .unwrap() + .push(record.worker.worker_id.clone()); + store + .upsert_worker_registry(&record) + .map(|commit| ((), commit)) + }) + .unwrap(); + })); + } + barrier.wait(); + for publisher in publishers { + publisher.join().unwrap(); + } + + let published_order = [ + subscription.try_recv().unwrap(), + subscription.try_recv().unwrap(), + ] + .into_iter() + .map(|event| match event.changes.as_slice() { + [WorkerCatalogChange::Upsert(record)] => record.registry.worker.worker_id.clone(), + changes => panic!("unexpected projection changes: {changes:?}"), + }) + .collect::>(); + assert_eq!(published_order, *durable_order.lock().unwrap()); + } +} diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index fb5d1a6a0..cfe89abdd 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -73,10 +73,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_orphan_diagnostics", "worker_registry", "worker_registry_observations", - "worker_registry_projection_cursors", "worker_registry_projection_diagnostics", "worker_registry_projection_removals", - "worker_registry_projection_revisions", "worker_removal_operations", "worker_retention_audit_events", "worker_session_archives", diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index afd4fbfbe..54f3182ae 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -271,7 +271,6 @@ async fn run_worker_protocol( worker_id: worker_id.clone(), runtime_id: Some(runtime_id), }, - snapshot_revision: 0, snapshot: SubscriptionSnapshot::WorkerProtocol { worker_id: worker_id.clone(), events: vec![initial_event], @@ -284,13 +283,10 @@ async fn run_worker_protocol( { return; } - let mut subject_revision = 0_u64; while let Some(event) = events.recv().await { - subject_revision = subject_revision.saturating_add(1); let frame = SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { subscription_id: subscription_id.clone(), - subject_revision, payload: SubscriptionEventPayload::WorkerProtocol { worker_id: worker_id.clone(), event, @@ -333,12 +329,10 @@ async fn run_workspace_workers( subscription_id: SubscriptionId, outbound: mpsc::Sender, ) { - // Subscribe before reading so commits racing with the snapshot are replayed. - let mut events = api.worker_projection.subscribe(); - let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot( - &api.config.workspace_id, - api.config.max_records.max(1), - ) else { + let Ok(mut projection) = api + .worker_projection + .subscribe_ordered(api.config.max_records.max(1)) + else { let _ = send_rejected( &outbound, request_id, @@ -348,7 +342,8 @@ async fn run_workspace_workers( .await; return; }; - let mut workers = records + let mut workers = projection + .take_snapshot() .into_iter() .filter_map(|record| project_registry_worker(&api, record)) .collect::>(); @@ -360,7 +355,6 @@ async fn run_workspace_workers( request_id, subscription_id: subscription_id.clone(), selector: EventSubscriptionSelector::WorkspaceWorkers, - snapshot_revision, snapshot: SubscriptionSnapshot::Workers { workers }, }, )), @@ -372,16 +366,14 @@ async fn run_workspace_workers( } loop { - match events.recv().await { - Ok(event) if event.revision <= snapshot_revision => continue, + match projection.recv().await { Ok(event) => { for change in event.changes { let payload = match change { crate::worker_projection::WorkerProjectionChange::Upsert(record) => { - let Some(mut worker) = project_registry_worker(&api, record) else { + let Some(worker) = project_registry_worker(&api, record) else { continue; }; - worker.subject_revision = event.revision; SubscriptionEventPayload::WorkerUpserted { worker } } crate::worker_projection::WorkerProjectionChange::Removed(worker) => { @@ -396,7 +388,7 @@ async fn run_workspace_workers( } } }; - if send_event(&outbound, &subscription_id, event.revision, payload) + if send_event(&outbound, &subscription_id, payload) .await .is_err() { @@ -431,7 +423,6 @@ fn project_registry_worker( let mut worker = if let Some(observation) = record.observation { let mut worker = observation.worker; worker.availability = observation.availability; - worker.subject_revision = observation.projection_revision; worker } else { SubscriptionWorker { @@ -442,7 +433,6 @@ fn project_registry_worker( runtime_id: Some(runtime_id.clone()), resource_key: record.resource_key.clone(), availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable, - subject_revision: 0, worker_state: None, state: protocol::subscription::SubscriptionWorkerState::Stopped, has_running_internal_workers: false, @@ -559,14 +549,12 @@ fn sort_workers(workers: &mut [SubscriptionWorker]) { async fn send_event( outbound: &mpsc::Sender, subscription_id: &SubscriptionId, - subject_revision: u64, payload: SubscriptionEventPayload, ) -> Result<(), ()> { send_frame( outbound, SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { subscription_id: subscription_id.clone(), - subject_revision, payload, })), ) @@ -617,7 +605,6 @@ mod tests { runtime_id: Some("runtime-1".to_string()), resource_key: Some("W-1".to_string()), availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, - subject_revision: 1, worker_state: None, state: protocol::subscription::SubscriptionWorkerState::Idle, has_running_internal_workers: false, diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts index 627def943..f4c79d17a 100644 --- a/tools/web-ux/browser-tests/console_history_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -227,7 +227,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (frame?.message?.method !== "subscribe_events") return; const requestId = frame.message.params.request_id; socket.send(JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: "response", message: { result: "subscribed", @@ -235,7 +235,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { request_id: requestId, subscription_id: "fixture-workers", selector: frame.message.params.selector, - snapshot_revision: 1, snapshot: { topic: "workers", data: { workers: [] } }, }, }, diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index ba1c24614..89fa3d098 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -129,7 +129,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (frame?.message?.method !== "subscribe_events") return; const requestId = frame.message.params.request_id; socket.send(JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: "response", message: { result: "subscribed", @@ -137,7 +137,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { request_id: requestId, subscription_id: "fixture-workers", selector: { topic: "workspace_workers" }, - snapshot_revision: 1, snapshot: { topic: "workers", data: { workers: [] } }, }, }, diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index b1bf91a9b..27a3d3667 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -138,7 +138,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const frame = JSON.parse(String(event.data)); if (frame?.message?.method !== "subscribe_events") return; socket.send(JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: "response", message: { result: "subscribed", @@ -146,7 +146,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { request_id: frame.message.params.request_id, subscription_id: "fixture-workers", selector: { topic: "workspace_workers" }, - snapshot_revision: 1, snapshot: { topic: "workers", data: { workers: [] } }, }, }, diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index e36d8205b..e2ab28acc 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -10,7 +10,7 @@ import type { WorkspaceSubscriptionWorkdir, } from './protocol'; -export const SUBSCRIPTION_PROTOCOL_VERSION = 1; +export const SUBSCRIPTION_PROTOCOL_VERSION = 2; export const MAX_SUBSCRIPTION_FRAME_JSON_BYTES = 16777216; export const MAX_SUBSCRIPTION_STRING_BYTES = 4194304; export const MAX_SUBSCRIPTION_COLLECTION_ITEMS = 100000; @@ -21,9 +21,9 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subject_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","subject_revision","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"snapshot_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot_revision","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"subject_revision":{"description":"Producer-owned monotonic revision for this Worker subject.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest revisioned foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","subject_revision","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = - [{"protocol_version":1,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot_revision":1,"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","subject_revision":1,"state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":1,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","subject_revision":2,"payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; + [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; const utf8 = new TextEncoder(); type JsonObject = Record; diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 8bae7b59a..7063b7214 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -304,11 +304,7 @@ resource_key?: string | null, */ availability: SubscriptionWorkerAvailability, /** - * Producer-owned monotonic revision for this Worker subject. - */ -subject_revision: number, -/** - * Latest revisioned foreground state observed from the Worker. This remains + * Latest foreground state observed from the Worker. This remains * absent until an authoritative Worker snapshot/event has been applied. */ worker_state?: WorkerStateSnapshot | null, @@ -336,9 +332,9 @@ export type SubscriptionRequest = { "method": "subscribe_events", "params": { re export type SubscriptionWorkerProtocolMethod = { subscription_id: SubscriptionId, method: Method, }; -export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot_revision: number, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } }; +export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } }; -export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, subject_revision: number, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } }; +export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } }; export type SubscriptionFramePayload = { "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod }; diff --git a/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts index 77ab8f56d..251ebe0fc 100644 --- a/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts +++ b/web/workspace/src/lib/workspace/multiplexer.browser.spec.ts @@ -83,7 +83,7 @@ test('invalid inbound frame closes before dispatch and reconnects for a fresh sn first.message( JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: 'event', message: { event: 'subscription_closed', @@ -141,7 +141,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele : { topic: 'workspace_workdirs', data: { workdirs: [] } }; socket.message( JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: 'response', message: { result: 'subscribed', @@ -149,7 +149,6 @@ test('valid subscription closure remains isolated and resubscribes only its sele request_id: request.message.params.request_id, subscription_id: `subscription-${index + 1}`, selector, - snapshot_revision: 1, snapshot, }, }, @@ -160,7 +159,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele const sentBeforeClosure = socket.sent.length; socket.message( JSON.stringify({ - protocol_version: 1, + protocol_version: 2, frame: 'event', message: { event: 'subscription_closed', diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index 453aaf9d5..f88cf9462 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -138,7 +138,7 @@ export class WorkspaceMultiplexer { subscription.requestId = requestId; this.#requests.set(requestId, subscription.clientId); this.#send({ - protocol_version: 1, + protocol_version: 2, frame: 'request', message: { method: 'subscribe_events', @@ -213,7 +213,7 @@ export class WorkspaceMultiplexer { const subscription = this.#subscriptions.get(clientId); if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open'); this.#send({ - protocol_version: 1, + protocol_version: 2, frame: 'worker_protocol', message: { subscription_id: subscription.subscriptionId, method }, }); @@ -225,7 +225,7 @@ export class WorkspaceMultiplexer { this.#subscriptions.delete(clientId); if (subscription.subscriptionId && this.#socket?.readyState === WebSocket.OPEN) { this.#send({ - protocol_version: 1, + protocol_version: 2, frame: 'request', message: { method: 'unsubscribe_events', diff --git a/web/workspace/src/lib/workspace/protocol-validator.test.ts b/web/workspace/src/lib/workspace/protocol-validator.test.ts index bffe6ced3..8683b6052 100644 --- a/web/workspace/src/lib/workspace/protocol-validator.test.ts +++ b/web/workspace/src/lib/workspace/protocol-validator.test.ts @@ -16,7 +16,7 @@ function assert(condition: unknown, message: string): asserts condition { function subscribedFrame(overrides: Record = {}): Record { return { - protocol_version: 1, + protocol_version: 2, frame: 'response', message: { result: 'subscribed', @@ -24,7 +24,6 @@ function subscribedFrame(overrides: Record = {}): Record { const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1); const result = decode({ - protocol_version: 1, + protocol_version: 2, frame: 'event', message: { event: 'event', data: { subscription_id: 'subscription-1', - subject_revision: 2, payload: { event: 'worker_protocol', data: { @@ -306,13 +299,12 @@ Deno.test('aggregate limits include keys in unconstrained Worker JSON values', ( Deno.test('stateful selector validation fences a routed event before projection', () => { const frame: SubscriptionFrame = { - protocol_version: 1, + protocol_version: 2, frame: 'event', message: { event: 'event', data: { subscription_id: 'subscription-1', - subject_revision: 2, payload: { event: 'worker_protocol', data: { worker_id: 'worker-2', event: { event: 'thinking_start' } }, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts index 1ff7485aa..ce03bfb8a 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-stop.browser.spec.ts @@ -43,7 +43,7 @@ function snapshot( availability: SubscriptionWorker["availability"], ): SubscriptionFrame { return { - protocol_version: 1, + protocol_version: 2, frame: "response", message: { result: "subscribed", @@ -51,7 +51,6 @@ function snapshot( request_id: "request-1", subscription_id: "subscription-1", selector: { topic: "workspace_workers" }, - snapshot_revision: 1, snapshot: { topic: "workers", data: { @@ -60,7 +59,6 @@ function snapshot( runtime_id: "runtime-a", resource_key: "W-1", availability, - subject_revision: 1, state, // An absent foreground snapshot must not prevent stopping an active catalog Worker. worker_state: null, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription-model.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription-model.ts index e2df7b0d4..6ec7107ce 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription-model.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription-model.ts @@ -6,55 +6,55 @@ import type { export type WorkspaceWorkersProjection = { workers: Map; - revisions: Map; + subscriptionId: string | null; }; export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection { - return { workers: new Map(), revisions: new Map() }; + return { workers: new Map(), subscriptionId: null }; } export function applyWorkspaceWorkersFrame( projection: WorkspaceWorkersProjection, frame: SubscriptionFrame, ): void { - if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol'); + if (frame.protocol_version !== 2) throw new Error('unsupported Worker subscription protocol'); if (frame.frame === 'response' && frame.message.result === 'subscribed') { if (frame.message.payload.selector.topic !== 'workspace_workers') return; const snapshot = frame.message.payload.snapshot; if (snapshot.topic !== 'workers') throw new Error('workspace_workers returned a non-Worker snapshot'); - projection.workers.clear(); - projection.revisions.clear(); + + // Build the replacement before mutating the live projection so one malformed + // Worker cannot partially clear the previous subscription lifetime. + const workers = new Map(); for (const worker of snapshot.data.workers) { - const key = workerKey(worker.runtime_id, worker.worker_id); - projection.workers.set(key, worker); - projection.revisions.set(key, worker.subject_revision); + workers.set(workerKey(worker.runtime_id, worker.worker_id), worker); } + projection.workers = workers; + projection.subscriptionId = frame.message.payload.subscription_id; return; } if (frame.frame !== 'event' || frame.message.event !== 'event') return; - applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload); + const subscriptionId = frame.message.data.subscription_id; + if (!projection.subscriptionId) { + throw new Error('workspace_workers event arrived before its snapshot'); + } + if (subscriptionId !== projection.subscriptionId) return; + applyPayload(projection, frame.message.data.payload); } function applyPayload( projection: WorkspaceWorkersProjection, - subjectRevision: number, payload: SubscriptionEventPayload, ): void { if (payload.event === 'worker_upserted') { const worker = payload.data.worker; - const key = workerKey(worker.runtime_id, worker.worker_id); - if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return; - projection.revisions.set(key, subjectRevision); - projection.workers.set(key, worker); + projection.workers.set(workerKey(worker.runtime_id, worker.worker_id), worker); } else if (payload.event === 'worker_removed') { - const key = workerKey(payload.data.runtime_id, payload.data.worker_id); - if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return; - projection.revisions.set(key, subjectRevision); - projection.workers.delete(key); + projection.workers.delete(workerKey(payload.data.runtime_id, payload.data.worker_id)); } } function workerKey(runtimeId: string | null | undefined, workerId: string): string { if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id'); - return `${runtimeId}:${workerId}`; + return JSON.stringify([runtimeId, workerId]); } diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts index 9ed0e52b4..cf23b0bbc 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts @@ -1,15 +1,29 @@ -import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol'; +import type { + SubscriptionEventPayload, + SubscriptionFrame, + SubscriptionWorker, +} from '$lib/generated/protocol'; + +import { liveWorkerState } from './worker-state'; +import { + applyWorkspaceWorkersFrame, + createWorkspaceWorkersProjection, +} from './worker-subscription-model'; function assertEquals(actual: unknown, expected: unknown): void { if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } } -import { liveWorkerState } from './worker-state'; -import { - applyWorkspaceWorkersFrame, - createWorkspaceWorkersProjection, -} from './worker-subscription-model'; + +function assertThrows(fn: () => void, message: string): void { + try { + fn(); + } catch { + return; + } + throw new Error(message); +} declare const Deno: { test(name: string, fn: () => void | Promise): void; @@ -18,7 +32,6 @@ declare const Deno: { function worker( runtimeId: string, workerId: string, - revision: number, hasRunningInternalWorkers = false, availability: 'observed' | 'unavailable' = 'observed', ): SubscriptionWorker { @@ -26,7 +39,6 @@ function worker( worker_id: workerId, runtime_id: runtimeId, availability, - subject_revision: revision, state: 'idle', has_running_internal_workers: hasRunningInternalWorkers, workspace_id: 'workspace-test', @@ -36,15 +48,52 @@ function worker( }; } +function key(runtimeId: string, workerId: string): string { + return JSON.stringify([runtimeId, workerId]); +} + +function snapshot( + subscriptionId: string, + workers: SubscriptionWorker[], +): SubscriptionFrame { + return { + protocol_version: 2, + frame: 'response', + message: { + result: 'subscribed', + payload: { + request_id: `request-${subscriptionId}`, + subscription_id: subscriptionId, + selector: { topic: 'workspace_workers' }, + snapshot: { topic: 'workers', data: { workers } }, + }, + }, + }; +} + +function event( + subscriptionId: string, + payload: SubscriptionEventPayload, +): SubscriptionFrame { + return { + protocol_version: 2, + frame: 'event', + message: { + event: 'event', + data: { subscription_id: subscriptionId, payload }, + }, + }; +} + Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => { - const active = worker('runtime-a', 'worker-1', 1); + const active = worker('runtime-a', 'worker-1'); active.worker_state = { last_command_id: 1, state: { kind: 'busy', state: { kind: 'run', state: 'paused' } }, }; assertEquals(liveWorkerState(active), 'paused'); - const unavailable = worker('runtime-a', 'worker-2', 1, false, 'unavailable'); + const unavailable = worker('runtime-a', 'worker-2', false, 'unavailable'); unavailable.worker_state = { last_command_id: 2, state: { kind: 'busy', state: { kind: 'run', state: 'running' } }, @@ -67,127 +116,118 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => { const projection = createWorkspaceWorkersProjection(); - const frame: SubscriptionFrame = { - protocol_version: 1, - frame: 'response', - message: { - result: 'subscribed', - payload: { - request_id: 'request-1', - subscription_id: 'subscription-1', - selector: { topic: 'workspace_workers' }, - snapshot_revision: 1, - snapshot: { - topic: 'workers', - data: { workers: [worker('runtime-a', '1', 1), worker('runtime-b', '1', 1)] }, - }, - }, - }, - }; - applyWorkspaceWorkersFrame(projection, frame); - assertEquals([...projection.workers.keys()].sort(), ['runtime-a:1', 'runtime-b:1']); + applyWorkspaceWorkersFrame( + projection, + snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]), + ); + assertEquals([...projection.workers.keys()].sort(), [key('runtime-a', '1'), key('runtime-b', '1')]); }); -Deno.test('workspace Worker snapshot and live update preserve canonical Job metadata', () => { +Deno.test('workspace Worker snapshot and ordered live update preserve canonical Job metadata', () => { const projection = createWorkspaceWorkersProjection(); - const snapshotWorker = worker('runtime-a', 'job-worker', 1); + const snapshotWorker = worker('runtime-a', 'job-worker'); snapshotWorker.job = { job_id: 'check:T-1:r1', attempt_id: 'check:T-1:r1:attempt:1', purpose: 'ticket_item_check', }; - applyWorkspaceWorkersFrame(projection, { - protocol_version: 1, - frame: 'response', - message: { - result: 'subscribed', - payload: { - request_id: 'request-1', - subscription_id: 'subscription-1', - selector: { topic: 'workspace_workers' }, - snapshot_revision: 1, - snapshot: { topic: 'workers', data: { workers: [snapshotWorker] } }, - }, - }, - }); - assertEquals(projection.workers.get('runtime-a:job-worker')?.job, snapshotWorker.job); + applyWorkspaceWorkersFrame(projection, snapshot('subscription-1', [snapshotWorker])); + assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, snapshotWorker.job); - const updated = { ...snapshotWorker, subject_revision: 2 }; + const updated = { ...snapshotWorker }; updated.job = { ...snapshotWorker.job, attempt_id: 'check:T-1:r1:attempt:2' }; - applyWorkspaceWorkersFrame(projection, { - protocol_version: 1, - frame: 'event', - message: { - event: 'event', - data: { - subscription_id: 'subscription-1', - subject_revision: 2, - payload: { event: 'worker_upserted', data: { worker: updated } }, - }, - }, - }); - assertEquals(projection.workers.get('runtime-a:job-worker')?.job, updated.job); + applyWorkspaceWorkersFrame( + projection, + event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }), + ); + assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, updated.job); }); -Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => { +Deno.test('fresh subscription snapshot replaces old state and fences delayed old events', () => { const projection = createWorkspaceWorkersProjection(); - projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3)); - projection.revisions.set('runtime-a:1', 3); + applyWorkspaceWorkersFrame( + projection, + snapshot('subscription-old', [worker('runtime-a', 'old'), worker('runtime-b', 'same')]), + ); + applyWorkspaceWorkersFrame( + projection, + event('subscription-old', { + event: 'worker_upserted', + data: { worker: worker('runtime-a', 'before-reconnect') }, + }), + ); - applyWorkspaceWorkersFrame(projection, { - protocol_version: 1, - frame: 'event', - message: { - event: 'event', - data: { - subscription_id: 'subscription-1', - subject_revision: 2, - payload: { event: 'worker_upserted', data: { worker: worker('runtime-a', '1', 2) } }, - }, - }, - }); - assertEquals(projection.revisions.get('runtime-a:1'), 3); + applyWorkspaceWorkersFrame( + projection, + snapshot('subscription-new', [worker('runtime-a', 'current')]), + ); + applyWorkspaceWorkersFrame( + projection, + event('subscription-old', { + event: 'worker_upserted', + data: { worker: worker('runtime-a', 'late-old-event') }, + }), + ); - applyWorkspaceWorkersFrame(projection, { - protocol_version: 1, - frame: 'event', - message: { - event: 'event', - data: { - subscription_id: 'subscription-1', - subject_revision: 4, - payload: { - event: 'worker_removed', - data: { worker_id: '1', runtime_id: 'runtime-a' }, - }, - }, - }, - }); - assertEquals(projection.workers.size, 0); - assertEquals(projection.revisions.get('runtime-a:1'), 4); + assertEquals(projection.subscriptionId, 'subscription-new'); + assertEquals([...projection.workers.keys()], [key('runtime-a', 'current')]); +}); + +Deno.test('workspace Worker reducer applies ordered updates and composite removal', () => { + const projection = createWorkspaceWorkersProjection(); + applyWorkspaceWorkersFrame( + projection, + snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]), + ); + + const updated = worker('runtime-a', '1'); + updated.state = 'running'; + applyWorkspaceWorkersFrame( + projection, + event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }), + ); + applyWorkspaceWorkersFrame( + projection, + event('subscription-1', { + event: 'worker_removed', + data: { worker_id: '1', runtime_id: 'runtime-a' }, + }), + ); + + assertEquals([...projection.workers.keys()], [key('runtime-b', '1')]); +}); + +Deno.test('workspace Worker event before a snapshot is rejected', () => { + const projection = createWorkspaceWorkersProjection(); + assertThrows( + () => + applyWorkspaceWorkersFrame( + projection, + event('subscription-1', { + event: 'worker_upserted', + data: { worker: worker('runtime-a', '1') }, + }), + ), + 'event-before-snapshot must be rejected', + ); }); Deno.test('fatal child stop replaces the running-child sidebar projection', () => { const projection = createWorkspaceWorkersProjection(); - projection.workers.set('runtime-a:1', worker('runtime-a', '1', 1, true)); - projection.revisions.set('runtime-a:1', 1); + applyWorkspaceWorkersFrame( + projection, + snapshot('subscription-1', [worker('runtime-a', '1', true)]), + ); + applyWorkspaceWorkersFrame( + projection, + event('subscription-1', { + event: 'worker_upserted', + data: { worker: worker('runtime-a', '1', false) }, + }), + ); - applyWorkspaceWorkersFrame(projection, { - protocol_version: 1, - frame: 'event', - message: { - event: 'event', - data: { - subscription_id: 'subscription-1', - subject_revision: 2, - payload: { - event: 'worker_upserted', - data: { worker: worker('runtime-a', '1', 2, false) }, - }, - }, - }, - }); - - assertEquals(projection.workers.get('runtime-a:1')?.has_running_internal_workers, false); - assertEquals(projection.revisions.get('runtime-a:1'), 2); + assertEquals( + projection.workers.get(key('runtime-a', '1'))?.has_running_internal_workers, + false, + ); }); From 046e606a38b1a48958cb82c2c5d7b08e4ce3f709 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 10:46:12 +0900 Subject: [PATCH 11/31] feat: connect subject memory consolidation --- crates/manifest/src/builtin_profile.rs | 7 + crates/manifest/src/profile.rs | 21 +- crates/server-api/src/lib.rs | 182 +++++ crates/worker/src/feature/builtin/memory.rs | 281 ++++++- .../src/feature/builtin/memory_lifecycle.rs | 56 +- crates/worker/src/prompt/catalog.rs | 14 + crates/workspace-server/src/server.rs | 571 ++++++++++++- crates/workspace-server/src/subjektiv.rs | 759 ++++++++++++++++-- openapi/server-api.json | 2 +- .../subjektiv-memory-consolidation.dcdl | 17 + resources/prompts/catalog.dcdl | 2 + .../subjektiv_memory_consolidation_system.md | 41 + .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 17 files changed, 1857 insertions(+), 106 deletions(-) create mode 100644 resources/profiles/subjektiv-memory-consolidation.dcdl create mode 100644 resources/prompts/internal/subjektiv_memory_consolidation_system.md diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs index b2d291787..e19b44956 100644 --- a/crates/manifest/src/builtin_profile.rs +++ b/crates/manifest/src/builtin_profile.rs @@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ description: "Internal Memory consolidation service.", imports: BASE_IMPORT, }, + BuiltinProfileResource { + selector: Some("builtin:subjektiv-memory-consolidation"), + path: "profiles/subjektiv-memory-consolidation.dcdl", + source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"), + description: "Delegated subject Memory consolidation service.", + imports: BASE_IMPORT, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 2e59e18ff..c504ce154 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1265,6 +1265,7 @@ mod tests { "coder", "reviewer", "memory-consolidation", + "subjektiv-memory-consolidation", ] { let role = resolve(name); assert_eq!( @@ -1311,15 +1312,17 @@ mod tests { ); } - let consolidation = resolve("memory-consolidation"); - assert_eq!( - consolidation.manifest.model.ref_.as_deref(), - Some("codex-oauth/gpt-5.6-luna") - ); - assert_eq!( - consolidation.manifest.engine.reasoning, - Some(ReasoningControl::Effort(ReasoningEffort::Medium)) - ); + for name in ["memory-consolidation", "subjektiv-memory-consolidation"] { + let consolidation = resolve(name); + assert_eq!( + consolidation.manifest.model.ref_.as_deref(), + Some("codex-oauth/gpt-5.6-luna") + ); + assert_eq!( + consolidation.manifest.engine.reasoning, + Some(ReasoningControl::Effort(ReasoningEffort::Medium)) + ); + } } #[test] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d3aea23ba..089d7c65c 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1190,6 +1190,19 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivMemoryBackendRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/consolidation", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_memory_consolidation( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: MemoryConsolidateStagingRequest, + ) -> Result; #[get( "/api/w/{workspace_id}/skills", @@ -4650,6 +4663,168 @@ pub struct SubjektivMemoryStageExplicitRequest { pub proposal: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateReadRequest { + pub candidate_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionProposalMetadata { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub expected_revision: u64, + pub intent: SubjektivMemoryRevisionIntent, + pub change_reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidate { + pub candidate_id: String, + pub kind: memory::extract::CandidateKind, + pub claim: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + pub source: memory::schema::SourceRef, + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + pub source_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision_proposal: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateSummary { + pub candidate_id: String, + pub kind: memory::extract::CandidateKind, + pub claim: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision_proposal: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateListResponse { + pub items: Vec, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubjektivMemoryApplyTarget { + Create, + Revise { + memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + expected_revision: u64, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryDraft { + pub kind: memory::extract::CandidateKind, + pub state: SubjektivMemoryState, + pub claim: String, + pub body_md: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + #[serde(default)] + pub derived_from: Vec, + pub change_reason: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryCandidateCloseAction { + Discarded, + Invalid, + Duplicate, + AlreadyCovered, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubjektivMemoryCandidateDecision { + Apply { + target: SubjektivMemoryApplyTarget, + memory: SubjektivMemoryDraft, + }, + Close { + action: SubjektivMemoryCandidateCloseAction, + #[serde(default)] + affected_memory: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateDecisionRequest { + pub request_id: String, + pub candidate_id: String, + pub reason: String, + pub decision: SubjektivMemoryCandidateDecision, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryAffectedOperation { + Create, + Revise, + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryAffectedRef { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub operation: SubjektivMemoryAffectedOperation, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivMemoryCandidateResolutionAction { + Applied, + Discarded, + Invalid, + Duplicate, + AlreadyCovered, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryCandidateDecisionResponse { + pub request_id: String, + pub candidate_id: String, + pub action: SubjektivMemoryCandidateResolutionAction, + pub reason: String, + #[serde(default)] + pub affected_memory: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub store_revision: u64, + pub surface_dirty: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { @@ -4659,6 +4834,9 @@ pub enum SubjektivMemoryBackendOperation { ValidateProposal(SubjektivMemoryValidateProposalRequest), ReceiptStatus(SubjektivMemoryReceiptStatusRequest), StageExplicit(SubjektivMemoryStageExplicitRequest), + ListCandidates(SubjektivMemoryCandidateListRequest), + ReadCandidate(SubjektivMemoryCandidateReadRequest), + DecideCandidate(SubjektivMemoryCandidateDecisionRequest), } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -4824,6 +5002,9 @@ pub enum SubjektivMemoryBackendResponse { ProposalValidated(SubjektivMemoryProposalValidationResponse), ReceiptStatus(SubjektivMemoryReceiptStatusResponse), Staged(SubjektivMemoryStageExplicitResponse), + Candidates(SubjektivMemoryCandidateListResponse), + Candidate(SubjektivMemoryCandidate), + CandidateDecided(SubjektivMemoryCandidateDecisionResponse), } pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; @@ -11994,6 +12175,7 @@ mod openapi_artifact_tests { "subjektiv_stage_candidate", "subjektiv_record_session", "subjektiv_memory_backend", + "subjektiv_memory_consolidation", "workspace_worker_discovery", "workspace_worker_remove", ]; diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 3198b5ea6..f3c707ff4 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -84,7 +84,14 @@ impl dyn WorkspaceClient + '_ { &self, operation: MemoryConsolidateStagingOperation, ) -> Result { - execute_memory_consolidation(self, operation).await + execute_memory_consolidation(self, operation, false).await + } + + pub async fn request_subjektiv_memory_staging_consolidation( + &self, + operation: MemoryConsolidateStagingOperation, + ) -> Result { + execute_memory_consolidation(self, operation, true).await } } @@ -125,6 +132,7 @@ async fn execute_memory_backend( async fn execute_memory_consolidation( client: &dyn WorkspaceClient, operation: MemoryConsolidateStagingOperation, + subjektiv: bool, ) -> Result { let workspace_id = client @@ -137,7 +145,14 @@ async fn execute_memory_consolidation( })?; let response = client.execute(WorkspaceRequest::json( WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/memory/consolidation"), + format!( + "/api/w/{workspace_id}/{}", + if subjektiv { + "subjektiv/consolidation" + } else { + "memory/consolidation" + } + ), serde_json::to_string(&server_api::MemoryConsolidateStagingRequest { force: operation.force, })?, @@ -202,6 +217,205 @@ pub fn workspace_http_memory_tools(client: Arc) -> Vec, +) -> Vec { + use SubjectConsolidationOperation as Operation; + [ + ( + "SubjektivMemoryQuery", + "Search current Memory revisions for the delegated subject.", + schema_for::(), + Operation::Query, + ), + ( + "SubjektivMemoryRead", + "Read one current or historical Memory revision for the delegated subject.", + schema_for::(), + Operation::Read, + ), + ( + "SubjektivMemoryListRevisions", + "List immutable revisions of one Memory for the delegated subject.", + schema_for::(), + Operation::ListRevisions, + ), + ( + "MemoryStagingList", + "List pending staging candidates for the delegated subject.", + schema_for::(), + Operation::ListCandidates, + ), + ( + "MemoryStagingRead", + "Read one pending candidate, including typed revision proposal metadata and provenance.", + schema_for::(), + Operation::ReadCandidate, + ), + ( + "MemoryApplyCandidate", + "Atomically apply or reject one subject candidate. A request_id is idempotent; application revalidates exact revision proposal metadata and returns concrete affected revision refs.", + schema_for::(), + Operation::DecideCandidate, + ), + ] + .into_iter() + .map(|(name, description, schema, operation)| { + let client = Arc::clone(&client); + Arc::new(move || { + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema.clone()), + Arc::new(SubjectConsolidationTool { + client: Arc::clone(&client), + operation, + }) as Arc, + ) + }) as ToolDefinition + }) + .collect() +} + +#[derive(Clone, Copy)] +enum SubjectConsolidationOperation { + Query, + Read, + ListRevisions, + ListCandidates, + ReadCandidate, + DecideCandidate, +} + +struct SubjectConsolidationTool { + client: Arc, + operation: SubjectConsolidationOperation, +} + +#[async_trait] +impl Tool for SubjectConsolidationTool { + async fn execute( + &self, + input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + use SubjectConsolidationOperation as Operation; + let operation = match self.operation { + Operation::Query => { + server_api::SubjektivMemoryBackendOperation::Query(parse_input(input_json)?) + } + Operation::Read => { + server_api::SubjektivMemoryBackendOperation::Read(parse_input(input_json)?) + } + Operation::ListRevisions => { + server_api::SubjektivMemoryBackendOperation::ListRevisions(parse_input(input_json)?) + } + Operation::ListCandidates => { + server_api::SubjektivMemoryBackendOperation::ListCandidates(parse_input( + input_json, + )?) + } + Operation::ReadCandidate => { + server_api::SubjektivMemoryBackendOperation::ReadCandidate(parse_input(input_json)?) + } + Operation::DecideCandidate => { + server_api::SubjektivMemoryBackendOperation::DecideCandidate(parse_input( + input_json, + )?) + } + }; + let workspace_id = self.client.workspace_id().ok_or_else(|| { + ToolError::ExecutionFailed( + "subjektiv consolidation requires Workspace authority".into(), + ) + })?; + let response = self + .client + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("/api/w/{workspace_id}/subjektiv/memory"), + serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation }) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, + )) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + let parsed = + serde_json::from_str::(&response.body).ok(); + if let Some((code, message)) = parsed.as_ref().and_then(|error| { + error + .diagnostics + .iter() + .find(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "revision_conflict" + | "candidate_decision_conflict" + | "subject_scope_mismatch" + ) + }) + .map(|diagnostic| (diagnostic.code.clone(), error.message.clone())) + }) { + return Ok(ToolOutput { + summary: format!("Candidate decision requires reread: {code}."), + content: Some( + serde_json::json!({ + "status": "error", + "error": { "code": code, "message": message } + }) + .to_string(), + ), + attachments: Vec::new(), + }); + } + let detail = parsed.map(|error| error.message).unwrap_or(response.body); + return Err(if matches!(response.status, 400 | 404 | 409 | 422) { + ToolError::InvalidArgument(detail) + } else { + ToolError::ExecutionFailed(detail) + }); + } + let response: server_api::SubjektivMemoryBackendResponse = + serde_json::from_str(&response.body) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + let summary = match &response { + server_api::SubjektivMemoryBackendResponse::Query(value) => { + format!("Found {} subject Memory item(s).", value.items.len()) + } + server_api::SubjektivMemoryBackendResponse::Read(value) => { + format!( + "Read Memory {} revision {}.", + value.memory_id, value.revision + ) + } + server_api::SubjektivMemoryBackendResponse::ListRevisions(value) => { + format!("Listed {} revision(s).", value.items.len()) + } + server_api::SubjektivMemoryBackendResponse::Candidates(value) => { + format!("Listed {} pending candidate(s).", value.items.len()) + } + server_api::SubjektivMemoryBackendResponse::Candidate(value) => { + format!("Read pending candidate {}.", value.candidate_id) + } + server_api::SubjektivMemoryBackendResponse::CandidateDecided(value) => { + format!("Candidate {} decision committed.", value.candidate_id) + } + _ => { + return Err(ToolError::ExecutionFailed( + "unexpected subjektiv consolidation response".into(), + )); + } + }; + Ok(ToolOutput { + summary, + content: Some( + serde_json::to_string_pretty(&response) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, + ), + attachments: Vec::new(), + }) + } +} + pub fn workspace_http_memory_consolidation_tools( client: Arc, ) -> Vec { @@ -407,16 +621,21 @@ impl MemoryFeatureInstallPlan { prompts: Arc, profile: Option, ) -> std::io::Result> { - let memory_consolidation_worker = profile.as_ref().is_some_and(|snapshot| { - matches!( - &snapshot.source, + let profile_name = profile + .as_ref() + .and_then(|snapshot| match &snapshot.source { manifest::ProfileSource::Registry { source: manifest::ProfileRegistrySource::Builtin, name, .. - } if name == "memory-consolidation" - ) - }); + } => Some(name.as_str()), + _ => None, + }); + let memory_consolidation_worker = matches!( + profile_name, + Some("memory-consolidation" | "subjektiv-memory-consolidation") + ); + let subjektiv_consolidation_worker = profile_name == Some("subjektiv-memory-consolidation"); config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; @@ -450,16 +669,24 @@ impl MemoryFeatureInstallPlan { let system_prompt_override = if memory_consolidation_worker { let language = settings.language; Some( - prompts - .memory_consolidation_system(&language) - .map_err(|error| std::io::Error::other(error.to_string()))?, + if subjektiv_consolidation_worker { + prompts.subjektiv_memory_consolidation_system(&language) + } else { + prompts.memory_consolidation_system(&language) + } + .map_err(|error| std::io::Error::other(error.to_string()))?, ) } else { None }; + let module = if subjektiv_consolidation_worker { + MemoryToolsFeature::from_tools(workspace_http_subjektiv_consolidation_tools(client)) + } else { + MemoryToolsFeature::new(client, config.profile.staging_tools) + }; Ok(Some(Self { - module: MemoryToolsFeature::new(client, config.profile.staging_tools), + module, resident_summary_source, system_prompt_override, resolved_config: config, @@ -481,6 +708,10 @@ impl MemoryToolsFeature { }; Self { tools } } + + fn from_tools(tools: Vec) -> Self { + Self { tools } + } } impl FeatureModule for MemoryToolsFeature { @@ -759,4 +990,30 @@ mod tests { assert!(names.contains(&"MemoryStagingRead".to_string())); assert!(names.contains(&"MemoryStagingClose".to_string())); } + + #[test] + fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() { + let tools = workspace_http_subjektiv_consolidation_tools(test_client()); + let names = tool_names(tools.clone()); + assert_eq!( + names, + vec![ + "MemoryApplyCandidate", + "MemoryStagingList", + "MemoryStagingRead", + "SubjektivMemoryListRevisions", + "SubjektivMemoryQuery", + "SubjektivMemoryRead", + ] + ); + let decision = tool_meta(tools, "MemoryApplyCandidate").to_string(); + assert!(decision.contains("request_id")); + assert!(decision.contains("expected_revision")); + assert!(decision.contains("already_covered")); + assert!(!decision.contains("subject_id")); + assert!(!decision.contains("workspace_id")); + assert!(!decision.contains("runtime_id")); + assert!(!decision.contains("worker_id")); + assert!(!decision.contains("source_candidate_ids")); + } } diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 7fb6863d9..da1bb9fe6 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -164,7 +164,7 @@ impl SubjektivLifecycleFeature { workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { + if !lifecycle_enabled || !config.profile.enabled { return Ok(None); } config @@ -712,8 +712,16 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { }; if !cancellation.is_cancelled() { context.generation_fence.ensure_current()?; - if self.config.profile.consolidation.request_enabled { - self.request_consolidation().await; + match self.target { + ExtractionTarget::WorkspaceMemory + if self.config.profile.consolidation.request_enabled => + { + self.request_consolidation(false).await; + } + ExtractionTarget::Subjektiv => { + self.request_consolidation(true).await; + } + ExtractionTarget::WorkspaceMemory => {} } } extraction @@ -750,20 +758,24 @@ impl MemoryLifecycleTask { Ok(()) } - async fn request_consolidation(&self) { + async fn request_consolidation(&self, subjektiv: bool) { let audit = WorkerAuditBase::new( memory::audit::AuditWorker::MemoryConsolidation, memory::audit::AuditTrigger::StagingBacklog, Some(model_audit_from_manifest(&self.manifest.model)), ) .with_memory_settings(&self.config); - match self - .workspace_client - .request_memory_staging_consolidation( - memory::backend::MemoryConsolidateStagingOperation { force: false }, - ) - .await - { + let operation = memory::backend::MemoryConsolidateStagingOperation { force: false }; + let request = if subjektiv { + self.workspace_client + .request_subjektiv_memory_staging_consolidation(operation) + .await + } else { + self.workspace_client + .request_memory_staging_consolidation(operation) + .await + }; + match request { Ok(output) => { tracing::debug!( status = output.status.as_str(), @@ -1079,6 +1091,7 @@ mod tests { ) -> Result { let is_subjektiv_stage = request.path.ends_with("/subjektiv/staging"); let is_subjektiv_session = request.path.ends_with("/subjektiv/sessions"); + let is_subjektiv_consolidation = request.path.ends_with("/subjektiv/consolidation"); let is_stage_candidate = request .body .as_deref() @@ -1088,6 +1101,18 @@ mod tests { .as_deref() .is_some_and(|body| body.contains("append_audit")); self.requests.lock().unwrap().push(request); + if is_subjektiv_consolidation { + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string(&server_api::MemoryConsolidationResponse { + status: "started".into(), + summary: "subject consolidation requested".into(), + candidate_count: 1, + total_bytes: 1, + }) + .unwrap(), + }); + } if is_subjektiv_session { return Ok(crate::worker::WorkspaceResponse { status: 200, @@ -1635,8 +1660,15 @@ permission = "write" request .body .as_deref() - .is_none_or(|body| !body.contains("append_audit") && body != "{\"force\":false}") + .is_none_or(|body| !body.contains("append_audit")) })); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/subjektiv/consolidation")) + .count(), + 1 + ); } #[tokio::test] diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index bba265bdd..13b32e874 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -94,6 +94,7 @@ pub enum WorkerPrompt { CompactSystem, MemoryExtractSystem, MemoryConsolidationSystem, + SubjektivMemoryConsolidationSystem, FlowVerifierSystem, BackendJobSystem, NotifyWrapper, @@ -112,6 +113,9 @@ impl WorkerPrompt { Self::CompactSystem => "internal.compact_system", Self::MemoryExtractSystem => "internal.memory_extract_system", Self::MemoryConsolidationSystem => "internal.memory_consolidation_system", + Self::SubjektivMemoryConsolidationSystem => { + "internal.subjektiv_memory_consolidation_system" + } Self::FlowVerifierSystem => "internal.flow_verifier_system", Self::BackendJobSystem => "internal.backend_job_system", Self::NotifyWrapper => "internal.notify_wrapper", @@ -131,6 +135,7 @@ impl WorkerPrompt { WorkerPrompt::CompactSystem, WorkerPrompt::MemoryExtractSystem, WorkerPrompt::MemoryConsolidationSystem, + WorkerPrompt::SubjektivMemoryConsolidationSystem, WorkerPrompt::FlowVerifierSystem, WorkerPrompt::BackendJobSystem, WorkerPrompt::NotifyWrapper, @@ -449,6 +454,15 @@ impl PromptCatalog { single("language", language), ) } + pub fn subjektiv_memory_consolidation_system( + &self, + language: &str, + ) -> Result { + self.render( + WorkerPrompt::SubjektivMemoryConsolidationSystem, + single("language", language), + ) + } pub fn flow_verifier_system(&self) -> Result { self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ba610e53c..dd8e703fd 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -7166,6 +7166,31 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_memory_consolidation( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::MemoryConsolidateStagingRequest, + ) -> std::result::Result + { + scoped_subjektiv_memory_consolidation( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(MemoryConsolidateStagingOperation { + force: request.force, + }), + ) + .await + .map(|Json(response)| server_api::MemoryConsolidationResponse { + status: response.status, + summary: response.summary, + candidate_count: response.candidate_count, + total_bytes: response.total_bytes, + }) + .map_err(ApiError::into_repository_api_error) + } + async fn skill_list( &self, workspace_id: String, @@ -16519,25 +16544,42 @@ async fn scoped_memory_backend_operation( } const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; +const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:"; fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|error| ApiError::from(Error::Store(error.to_string()))) } -fn subjektiv_subject_id_from_singleton_key(key: &str) -> ApiResult<&str> { - key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) - .filter(|subject_id| { - !subject_id.is_empty() - && !subject_id.chars().any(char::is_control) - && subject_id.trim() == *subject_id - }) - .ok_or_else(|| { - Error::WorkspacePermissionDenied( - "current Worker is not connected to a subjektiv subject".to_string(), - ) - .into() - }) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubjektivWorkerAuthority { + Subject, + Consolidation, +} + +fn subjektiv_scope_from_singleton_key(key: &str) -> ApiResult<(&str, SubjektivWorkerAuthority)> { + let (subject_id, authority) = if let Some(subject_id) = + key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) + { + (subject_id, SubjektivWorkerAuthority::Subject) + } else if let Some(subject_id) = key.strip_prefix(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX) { + (subject_id, SubjektivWorkerAuthority::Consolidation) + } else { + return Err(Error::WorkspacePermissionDenied( + "current Worker is not connected to a subjektiv subject".to_string(), + ) + .into()); + }; + if subject_id.is_empty() + || subject_id.chars().any(char::is_control) + || subject_id.trim() != subject_id + { + return Err(Error::WorkspacePermissionDenied( + "current Worker is not connected to a subjektiv subject".to_string(), + ) + .into()); + } + Ok((subject_id, authority)) } fn subjektiv_singleton_key(subject_id: &str) -> ApiResult { @@ -16553,7 +16595,7 @@ fn subjektiv_subject_scope( api: &WorkspaceApi, workspace_id: &str, context: &server_api::ServerRequestContext, -) -> ApiResult<(String, RuntimeWorkerRef)> { +) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> { let source = context.runtime_source.as_ref().ok_or_else(|| { Error::WorkspacePermissionDenied( "subjektiv operations require authenticated Runtime-owned source authority".to_string(), @@ -16573,8 +16615,23 @@ fn subjektiv_subject_scope( "current Worker has no keyed singleton ownership".to_string(), ) })?; - let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?.to_string(); - Ok((subject_id, worker)) + let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?; + Ok((subject_id.to_string(), worker, authority)) +} + +fn require_subjektiv_worker_authority( + actual: SubjektivWorkerAuthority, + expected: SubjektivWorkerAuthority, + operation: &str, +) -> ApiResult<()> { + if actual == expected { + Ok(()) + } else { + Err(Error::WorkspacePermissionDenied(format!( + "subjektiv {operation} is not authorized for this Worker role" + )) + .into()) + } } fn subjektiv_session_attribution( @@ -16583,7 +16640,13 @@ fn subjektiv_session_attribution( context: &server_api::ServerRequestContext, session_id: &str, ) -> ApiResult { - let (subject_id, worker) = subjektiv_subject_scope(api, workspace_id, context)?; + let (subject_id, worker, authority) = subjektiv_subject_scope(api, workspace_id, context)?; + if authority != SubjektivWorkerAuthority::Subject { + return Err(Error::WorkspacePermissionDenied( + "subject Session attribution requires the current subject Worker".into(), + ) + .into()); + } let session_id = session_id.trim(); if session_id.is_empty() || session_id.chars().any(char::is_control) { return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into()); @@ -16824,7 +16887,7 @@ async fn scoped_subjektiv_memory_backend( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let (subject_id, _) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; let store = open_subjektiv_store(&api)?; let response = match request.operation { server_api::SubjektivMemoryBackendOperation::Query(input) => { @@ -16847,11 +16910,21 @@ async fn scoped_subjektiv_memory_backend( ) } server_api::SubjektivMemoryBackendOperation::ValidateProposal(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "revision proposal validation", + )?; server_api::SubjektivMemoryBackendResponse::ProposalValidated( subjektiv_memory_validate_proposal(&store, &subject_id, input)?, ) } server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "explicit candidate receipt lookup", + )?; validate_subjektiv_receipt_id(&input.receipt_id)?; let candidate = store .staging_candidate(&subject_id, &input.receipt_id) @@ -16869,6 +16942,11 @@ async fn scoped_subjektiv_memory_backend( ) } server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "explicit candidate staging", + )?; // Re-derive the same subject and immutable Session attribution for the // write. The request never carries subject, Worker, Runtime, origin, // or arbitrary source JSON from the model. @@ -16884,10 +16962,264 @@ async fn scoped_subjektiv_memory_backend( attribution, )?) } + server_api::SubjektivMemoryBackendOperation::ListCandidates(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "candidate listing", + )?; + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let mut candidates = store + .pending_staging_candidates(&subject_id, limit.saturating_add(1)) + .map_err(subjektiv_store_error)?; + let has_more = candidates.len() > limit; + candidates.truncate(limit); + server_api::SubjektivMemoryBackendResponse::Candidates( + server_api::SubjektivMemoryCandidateListResponse { + items: candidates + .into_iter() + .map(subjektiv_candidate_summary) + .collect(), + has_more, + }, + ) + } + server_api::SubjektivMemoryBackendOperation::ReadCandidate(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "candidate read", + )?; + let candidate = store + .staging_candidate(&subject_id, &input.candidate_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + Error::InvalidInput(format!( + "candidate_not_found: pending candidate `{}` was not found", + input.candidate_id + )) + })?; + if store + .staging_resolution(&subject_id, &input.candidate_id) + .map_err(subjektiv_store_error)? + .is_some() + { + return Err(Error::RepositoryConflict(format!( + "candidate_resolved: candidate `{}` is already resolved", + input.candidate_id + )) + .into()); + } + server_api::SubjektivMemoryBackendResponse::Candidate(subjektiv_candidate(candidate)) + } + server_api::SubjektivMemoryBackendOperation::DecideCandidate(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "candidate decision", + )?; + let receipt = store + .decide_candidate(&subject_id, subjektiv_candidate_decision(input)?) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::CandidateDecided( + subjektiv_candidate_decision_response(receipt), + ) + } }; Ok(Json(response)) } +fn subjektiv_candidate_summary( + candidate: crate::subjektiv::SubjectStagingRecord, +) -> server_api::SubjektivMemoryCandidateSummary { + server_api::SubjektivMemoryCandidateSummary { + candidate_id: candidate.id, + kind: candidate.kind, + claim: candidate.claim, + revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + created_at: candidate.created_at, + } +} + +fn subjektiv_candidate( + candidate: crate::subjektiv::SubjectStagingRecord, +) -> server_api::SubjektivMemoryCandidate { + server_api::SubjektivMemoryCandidate { + candidate_id: candidate.id, + kind: candidate.kind, + claim: candidate.claim, + why_useful: candidate.why_useful, + staleness: candidate.staleness, + source: candidate.source, + evidence: candidate.evidence, + source_refs: candidate.source_refs, + revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + created_at: candidate.created_at, + } +} + +fn subjektiv_proposal_metadata( + proposal: crate::subjektiv::RevisionProposal, +) -> server_api::SubjektivMemoryRevisionProposalMetadata { + server_api::SubjektivMemoryRevisionProposalMetadata { + memory_id: proposal.memory_id, + expected_revision: proposal.expected_revision, + intent: match proposal.intent { + crate::subjektiv::RevisionProposalIntent::Revise => { + server_api::SubjektivMemoryRevisionIntent::Revise + } + crate::subjektiv::RevisionProposalIntent::Resolve => { + server_api::SubjektivMemoryRevisionIntent::Resolve + } + crate::subjektiv::RevisionProposalIntent::Retract => { + server_api::SubjektivMemoryRevisionIntent::Retract + } + crate::subjektiv::RevisionProposalIntent::Reopen => { + server_api::SubjektivMemoryRevisionIntent::Reopen + } + }, + change_reason: proposal.change_reason, + } +} + +fn subjektiv_candidate_decision( + input: server_api::SubjektivMemoryCandidateDecisionRequest, +) -> ApiResult { + let decision = match input.decision { + server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { + let target = match target { + server_api::SubjektivMemoryApplyTarget::Create => { + crate::subjektiv::MemoryRevisionTarget::Create + } + server_api::SubjektivMemoryApplyTarget::Revise { + memory_id, + expected_revision, + } => crate::subjektiv::MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + }, + }; + crate::subjektiv::CandidateDecision::Apply { + target, + draft: crate::subjektiv::MemoryDraft { + kind: memory.kind, + state: domain_memory_state(memory.state), + claim: memory.claim, + body_md: memory.body_md, + why_useful: memory.why_useful, + staleness: memory.staleness, + source_candidate_ids: Vec::new(), + derived_from: memory + .derived_from + .into_iter() + .map(|reference| crate::subjektiv::MemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + change_reason: memory.change_reason, + }, + } + } + server_api::SubjektivMemoryCandidateDecision::Close { + action, + affected_memory, + } => crate::subjektiv::CandidateDecision::Close { + action: match action { + server_api::SubjektivMemoryCandidateCloseAction::Discarded => { + crate::subjektiv::StagingResolutionAction::Discarded + } + server_api::SubjektivMemoryCandidateCloseAction::Invalid => { + crate::subjektiv::StagingResolutionAction::Invalid + } + server_api::SubjektivMemoryCandidateCloseAction::Duplicate => { + crate::subjektiv::StagingResolutionAction::Duplicate + } + server_api::SubjektivMemoryCandidateCloseAction::AlreadyCovered => { + crate::subjektiv::StagingResolutionAction::AlreadyCovered + } + }, + affected_memory: affected_memory + .into_iter() + .map(|reference| crate::subjektiv::MemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + }, + }; + Ok(crate::subjektiv::CandidateDecisionRequest { + request_id: input.request_id, + candidate_id: input.candidate_id, + reason: input.reason, + decision, + }) +} + +fn subjektiv_candidate_decision_response( + receipt: crate::subjektiv::CandidateDecisionReceipt, +) -> server_api::SubjektivMemoryCandidateDecisionResponse { + let operation = match receipt.operation { + Some(crate::subjektiv::MemoryDecisionOperation::Create) => { + server_api::SubjektivMemoryAffectedOperation::Create + } + Some(crate::subjektiv::MemoryDecisionOperation::Revise) => { + server_api::SubjektivMemoryAffectedOperation::Revise + } + Some(crate::subjektiv::MemoryDecisionOperation::Reference) | None => { + server_api::SubjektivMemoryAffectedOperation::Reference + } + }; + server_api::SubjektivMemoryCandidateDecisionResponse { + request_id: receipt.request_id, + candidate_id: receipt.candidate_id, + action: match receipt.resolution.action { + crate::subjektiv::StagingResolutionAction::Applied => { + server_api::SubjektivMemoryCandidateResolutionAction::Applied + } + crate::subjektiv::StagingResolutionAction::Discarded => { + server_api::SubjektivMemoryCandidateResolutionAction::Discarded + } + crate::subjektiv::StagingResolutionAction::Invalid => { + server_api::SubjektivMemoryCandidateResolutionAction::Invalid + } + crate::subjektiv::StagingResolutionAction::Duplicate => { + server_api::SubjektivMemoryCandidateResolutionAction::Duplicate + } + crate::subjektiv::StagingResolutionAction::AlreadyCovered => { + server_api::SubjektivMemoryCandidateResolutionAction::AlreadyCovered + } + }, + reason: receipt.resolution.reason, + affected_memory: receipt + .resolution + .affected_memory + .into_iter() + .map(|reference| server_api::SubjektivMemoryAffectedRef { + memory_id: reference.memory_id, + revision: reference.revision, + operation, + }) + .collect(), + memory: receipt + .memory + .map(|memory| server_api::SubjektivMemoryRevisionRef { + memory_id: memory.id, + revision: memory.revision, + }), + store_revision: receipt.store_revision, + surface_dirty: receipt.surface_dirty, + } +} + +fn domain_memory_state(state: server_api::SubjektivMemoryState) -> crate::subjektiv::MemoryState { + match state { + server_api::SubjektivMemoryState::Active => crate::subjektiv::MemoryState::Active, + server_api::SubjektivMemoryState::Resolved => crate::subjektiv::MemoryState::Resolved, + server_api::SubjektivMemoryState::Retracted => crate::subjektiv::MemoryState::Retracted, + } +} + fn subjektiv_memory_query( store: &crate::subjektiv::SubjektivStore, subject_id: &str, @@ -17842,6 +18174,10 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { crate::subjektiv::SubjektivError::RevisionConflict { .. } => { Error::RepositoryConflict(format!("revision_conflict: {error}")) } + crate::subjektiv::SubjektivError::CandidateResolved(_) + | crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => { + Error::RepositoryConflict(format!("candidate_decision_conflict: {error}")) + } crate::subjektiv::SubjektivError::MemoryNotFound(_) => { Error::InvalidInput(format!("memory_not_found: {error}")) } @@ -17851,8 +18187,185 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { } const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; +const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; +async fn scoped_subjektiv_memory_consolidation( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(operation): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "consolidation request", + )?; + Ok(Json(start_subjektiv_staging_consolidation( + api, + &subject_id, + operation, + )?)) +} + +fn start_subjektiv_staging_consolidation( + api: WorkspaceApi, + subject_id: &str, + operation: MemoryConsolidateStagingOperation, +) -> ApiResult { + let (candidate_count, total_bytes) = open_subjektiv_store(&api)? + .pending_staging_backlog(subject_id) + .map_err(subjektiv_store_error)?; + if candidate_count == 0 { + return Ok(MemoryConsolidationOutput { + status: "skipped_empty".into(), + summary: "No subject Memory staging candidates are pending.".into(), + candidate_count, + total_bytes, + }); + } + const CONSOLIDATION_THRESHOLD_FILES: usize = 5; + const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; + if !operation.force + && candidate_count < CONSOLIDATION_THRESHOLD_FILES + && total_bytes < CONSOLIDATION_THRESHOLD_BYTES + { + return Ok(MemoryConsolidationOutput { + status: "skipped_below_threshold".into(), + summary: format!( + "Subject Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below Backend policy threshold." + ), + candidate_count, + total_bytes, + }); + } + + let runtime_id = select_memory_consolidation_runtime(&api)?; + let singleton_key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}"); + let input_content = format!( + "Process the delegated subject's {candidate_count} pending Memory candidate(s) ({total_bytes} bytes) with MemoryStagingList, MemoryStagingRead, subject Memory reads, and MemoryApplyCandidate." + ); + if let Some(output) = try_reuse_subject_consolidation_worker( + &api, + &singleton_key, + &input_content, + candidate_count, + total_bytes, + )? { + return Ok(output); + } + let result = api.spawn_workspace_worker( + &runtime_id, + WorkerSpawnRequest { + requested_worker_name: Some(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE.to_string()), + singleton_key: Some(singleton_key), + intent: WorkerSpawnIntent::WorkspaceOrchestrator, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 1, + }, + profile: ProfileSelector::Builtin(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE.to_string()), + ticket_assignment: None, + initial_submit: vec![Segment::text(input_content)], + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_control_operation: None, + }, + )?; + if result.state != InternalWorkerOperationState::Accepted { + return Ok(MemoryConsolidationOutput { + status: "skipped_spawn_rejected".into(), + summary: "Runtime rejected subject Memory consolidater spawn.".into(), + candidate_count, + total_bytes, + }); + } + let Some(worker) = result.worker else { + return Ok(MemoryConsolidationOutput { + status: "skipped_spawn_missing_worker".into(), + summary: "Runtime accepted subject Memory consolidater spawn without returning a Worker summary.".into(), + candidate_count, + total_bytes, + }); + }; + Ok(MemoryConsolidationOutput { + status: "started".into(), + summary: format!( + "Started subject Memory consolidater '{}' for {candidate_count} staging candidate(s).", + worker.worker.worker_id + ), + candidate_count, + total_bytes, + }) +} + +fn try_reuse_subject_consolidation_worker( + api: &WorkspaceApi, + singleton_key: &str, + input_content: &str, + candidate_count: usize, + total_bytes: u64, +) -> ApiResult> { + let Some(owner) = api + .store + .current_worker_singleton_owner(&api.config.workspace_id, singleton_key)? + else { + return Ok(None); + }; + let mut worker = api + .runtime + .worker(&owner.worker) + .map_err(|error| error.into_error())?; + worker.singleton_key = Some(owner.key); + if worker.state != "idle" { + return Ok(Some(MemoryConsolidationOutput { + status: "skipped_existing_not_idle".into(), + summary: format!( + "Existing subject Memory consolidater '{}' is '{}', not confirmed idle.", + worker.worker.worker_id, worker.state + ), + candidate_count, + total_bytes, + })); + } + let input = api + .runtime + .send_input( + &worker.worker, + WorkerInputRequest { + kind: WorkerInputKind::User, + content: input_content.to_string(), + submission_request_id: None, + segments: None, + }, + ) + .map_err(|error| error.into_error())?; + if input.state != InternalWorkerOperationState::Accepted { + return Ok(Some(MemoryConsolidationOutput { + status: "skipped_existing_input_rejected".into(), + summary: "Existing subject Memory consolidater rejected new input.".into(), + candidate_count, + total_bytes, + })); + } + Ok(Some(MemoryConsolidationOutput { + status: "reused".into(), + summary: format!( + "Reused subject Memory consolidater '{}' for {candidate_count} staging candidate(s).", + worker.worker.worker_id + ), + candidate_count, + total_bytes, + })) +} + async fn scoped_memory_consolidation( State(api): State, AxumPath(path): AxumPath, @@ -50647,12 +51160,26 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); #[test] fn subjektiv_singleton_scope_is_explicit_and_opaque() { assert_eq!( - subjektiv_subject_id_from_singleton_key("subjektiv:subject-42").unwrap(), + subjektiv_scope_from_singleton_key("subjektiv:subject-42") + .unwrap() + .0, "subject-42" ); - assert!(subjektiv_subject_id_from_singleton_key("workspace-orchestrator").is_err()); - assert!(subjektiv_subject_id_from_singleton_key("subjektiv:").is_err()); - assert!(subjektiv_subject_id_from_singleton_key("subjektiv: subject-42").is_err()); + assert_eq!( + subjektiv_scope_from_singleton_key("subjektiv-consolidation:subject-42") + .unwrap() + .1, + SubjektivWorkerAuthority::Consolidation + ); + assert_eq!( + subjektiv_scope_from_singleton_key("subjektiv:subject-42") + .unwrap() + .1, + SubjektivWorkerAuthority::Subject + ); + assert!(subjektiv_scope_from_singleton_key("workspace-orchestrator").is_err()); + assert!(subjektiv_scope_from_singleton_key("subjektiv:").is_err()); + assert!(subjektiv_scope_from_singleton_key("subjektiv: subject-42").is_err()); assert_eq!( subjektiv_singleton_key("subject-42").unwrap(), "subjektiv:subject-42" diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 9c3d4c59b..a5b92a4f9 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -42,6 +42,8 @@ pub enum SubjektivError { CandidateConflict(String), #[error("staging candidate `{0}` is already resolved")] CandidateResolved(String), + #[error("candidate decision request `{0}` was already used with different input")] + DecisionRequestConflict(String), #[error( "session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`" )] @@ -336,7 +338,8 @@ pub struct MemoryRecord { pub updated_at: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MemoryDraft { pub kind: CandidateKind, pub state: MemoryState, @@ -411,7 +414,8 @@ pub struct SurfaceSnapshot { pub created_at: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryRevisionTarget { Create, Revise { @@ -420,6 +424,51 @@ pub enum MemoryRevisionTarget { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryDecisionOperation { + Create, + Revise, + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CandidateDecision { + Apply { + target: MemoryRevisionTarget, + draft: MemoryDraft, + }, + Close { + action: StagingResolutionAction, + #[serde(default)] + affected_memory: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateDecisionRequest { + pub request_id: String, + pub candidate_id: String, + pub reason: String, + pub decision: CandidateDecision, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateDecisionReceipt { + pub request_id: String, + pub candidate_id: String, + pub resolution: StagingResolution, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + pub store_revision: u64, + pub surface_dirty: bool, +} + fn create_schema(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { transaction.execute_batch( r#" @@ -761,6 +810,37 @@ END; Ok(()) } +fn add_candidate_decision_receipts( + transaction: &Transaction<'_>, +) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE candidate_decision_receipts ( + subject_id TEXT NOT NULL, + request_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + request_json TEXT NOT NULL, + receipt_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, request_id), + UNIQUE (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TRIGGER candidate_decision_receipts_no_update +BEFORE UPDATE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); +END; +CREATE TRIGGER candidate_decision_receipts_no_delete +BEFORE DELETE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); +END; +"#, + )?; + Ok(()) +} + static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -768,6 +848,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add immutable subject session attribution", add_subject_session_attribution, ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), ]; pub const REGISTRATION: FeatureRegistration = @@ -1024,6 +1109,56 @@ impl SubjektivStore { }) } + pub fn pending_staging_candidates( + &self, + subject_id: &str, + limit: usize, + ) -> Result> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT s.record_json + FROM staging_records s + LEFT JOIN staging_resolutions r + ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id + WHERE s.subject_id = ?1 AND r.candidate_id IS NULL + ORDER BY s.created_at ASC, s.candidate_id ASC + LIMIT ?2", + )?; + let rows = statement.query_map(params![subject_id, to_i64(limit as u64)?], |row| { + row.get::<_, String>(0) + })?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_staging(self.workspace_id(), &row?)?); + } + Ok(records) + }) + } + + pub fn pending_staging_backlog(&self, subject_id: &str) -> Result<(usize, u64)> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let (count, bytes) = connection.query_row( + "SELECT COUNT(*), COALESCE(SUM(LENGTH(s.record_json)), 0) + FROM staging_records s + LEFT JOIN staging_resolutions r + ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id + WHERE s.subject_id = ?1 AND r.candidate_id IS NULL", + [subject_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?; + Ok(( + usize::try_from(count).map_err(|_| { + SubjektivError::InvalidRecord("pending candidate count exceeds usize".into()) + })?, + u64::try_from(bytes).map_err(|_| { + SubjektivError::InvalidRecord("pending candidate bytes are negative".into()) + })?, + )) + }) + } + pub fn create_memory(&self, subject_id: &str, draft: MemoryDraft) -> Result { validate_direct_memory_draft(&draft)?; if draft.state != MemoryState::Active { @@ -1080,7 +1215,7 @@ impl SubjektivStore { subject_id: &str, candidate_ids: &[String], target: MemoryRevisionTarget, - mut draft: MemoryDraft, + draft: MemoryDraft, reason: impl Into, ) -> Result<(MemoryRecord, Vec)> { if candidate_ids.is_empty() { @@ -1092,52 +1227,127 @@ impl SubjektivStore { for candidate_id in candidate_ids { validate_label("candidate id", candidate_id)?; } - draft.source_candidate_ids = candidate_ids.to_vec(); let reason = reason.into(); validate_nonempty("resolution reason", &reason)?; - let new_memory_id = issued_id("memory"); self.database.try_transaction(|transaction| { - for candidate_id in candidate_ids { - require_unresolved_candidate(transaction, subject_id, candidate_id)?; - } - let memory = match &target { - MemoryRevisionTarget::Create => { - if draft.state != MemoryState::Active { - return Err(SubjektivError::InvalidRecord( - "a new Memory must start in active state".to_string(), - )); - } - write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)? + apply_candidates_in_transaction( + transaction, + subject_id, + candidate_ids, + &target, + draft, + &reason, + ) + }) + } + + pub fn decide_candidate( + &self, + subject_id: &str, + request: CandidateDecisionRequest, + ) -> Result { + validate_label("candidate decision request id", &request.request_id)?; + validate_label("candidate id", &request.candidate_id)?; + validate_nonempty("resolution reason", &request.reason)?; + let request_raw = serde_json::to_string(&request)?; + self.database.try_transaction(|transaction| { + if let Some((stored_request, stored_receipt)) = transaction + .query_row( + "SELECT request_json, receipt_json + FROM candidate_decision_receipts + WHERE subject_id = ?1 AND request_id = ?2", + params![subject_id, request.request_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()? + { + if stored_request == request_raw { + return serde_json::from_str(&stored_receipt).map_err(Into::into); } - MemoryRevisionTarget::Revise { - memory_id, - expected_revision, - } => write_memory_revision( - transaction, - subject_id, - memory_id, - Some(*expected_revision), - draft, - )?, - }; - let reference = MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: memory.revision, - }; - let resolutions = candidate_ids - .iter() - .map(|candidate_id| { - insert_resolution( + return Err(SubjektivError::DecisionRequestConflict( + request.request_id.clone(), + )); + } + if transaction + .query_row( + "SELECT 1 FROM candidate_decision_receipts + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, request.candidate_id], + |_| Ok(()), + ) + .optional()? + .is_some() + { + return Err(SubjektivError::CandidateResolved( + request.candidate_id.clone(), + )); + } + + let (memory, operation, resolution) = match request.decision.clone() { + CandidateDecision::Apply { target, draft } => { + let operation = match &target { + MemoryRevisionTarget::Create => MemoryDecisionOperation::Create, + MemoryRevisionTarget::Revise { .. } => MemoryDecisionOperation::Revise, + }; + let candidate_ids = [request.candidate_id.clone()]; + let (memory, mut resolutions) = apply_candidates_in_transaction( transaction, subject_id, - candidate_id, - StagingResolutionAction::Applied, - &reason, - std::slice::from_ref(&reference), - ) - }) - .collect::>>()?; - Ok((memory, resolutions)) + &candidate_ids, + &target, + draft, + &request.reason, + )?; + (Some(memory), Some(operation), resolutions.remove(0)) + } + CandidateDecision::Close { + action, + affected_memory, + } => { + if action == StagingResolutionAction::Applied { + return Err(SubjektivError::InvalidRecord( + "applied candidates require an apply decision".into(), + )); + } + let resolution = insert_resolution( + transaction, + subject_id, + &request.candidate_id, + action, + &request.reason, + &affected_memory, + )?; + let operation = + (!affected_memory.is_empty()).then_some(MemoryDecisionOperation::Reference); + (None, operation, resolution) + } + }; + let store_revision = require_subject(transaction, subject_id)?.store_revision; + let receipt = CandidateDecisionReceipt { + request_id: request.request_id.clone(), + candidate_id: request.candidate_id.clone(), + surface_dirty: memory.is_some(), + memory, + operation, + resolution, + store_revision, + }; + let receipt_raw = serde_json::to_string(&receipt)?; + transaction.execute( + "INSERT INTO candidate_decision_receipts ( + subject_id, request_id, candidate_id, request_json, + receipt_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + subject_id, + request.request_id, + request.candidate_id, + request_raw, + receipt_raw, + now() + ], + )?; + Ok(receipt) }) } @@ -1390,6 +1600,64 @@ impl SubjektivStore { } } +fn apply_candidates_in_transaction( + transaction: &Transaction<'_>, + subject_id: &str, + candidate_ids: &[String], + target: &MemoryRevisionTarget, + mut draft: MemoryDraft, + reason: &str, +) -> Result<(MemoryRecord, Vec)> { + let mut candidates = Vec::with_capacity(candidate_ids.len()); + for candidate_id in candidate_ids { + let raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?; + candidates.push(parse_staging_without_workspace(&raw)?); + } + for candidate in &candidates { + validate_candidate_application(transaction, subject_id, candidate, target, &draft)?; + } + draft.source_candidate_ids = candidate_ids.to_vec(); + let new_memory_id = issued_id("memory"); + let memory = match target { + MemoryRevisionTarget::Create => { + if draft.state != MemoryState::Active { + return Err(SubjektivError::InvalidRecord( + "a new Memory must start in active state".into(), + )); + } + write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)? + } + MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + } => write_memory_revision( + transaction, + subject_id, + memory_id, + Some(*expected_revision), + draft, + )?, + }; + let reference = MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: memory.revision, + }; + let resolutions = candidate_ids + .iter() + .map(|candidate_id| { + insert_resolution( + transaction, + subject_id, + candidate_id, + StagingResolutionAction::Applied, + reason, + std::slice::from_ref(&reference), + ) + }) + .collect::>>()?; + Ok((memory, resolutions)) +} + fn write_memory_revision( transaction: &Transaction<'_>, subject_id: &str, @@ -1744,8 +2012,26 @@ fn require_unresolved_candidate( params![subject_id, candidate_id], |row| row.get::<_, String>(0), ) - .optional()? - .ok_or_else(|| SubjektivError::CandidateNotFound(candidate_id.to_string()))?; + .optional()?; + let raw = match raw { + Some(raw) => raw, + None => { + let foreign = transaction + .query_row( + "SELECT subject_id FROM staging_records WHERE candidate_id = ?1 LIMIT 1", + [candidate_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if foreign.is_some() { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: candidate_id.to_string(), + }); + } + return Err(SubjektivError::CandidateNotFound(candidate_id.to_string())); + } + }; let resolved = transaction .query_row( "SELECT 1 FROM staging_resolutions @@ -2001,6 +2287,74 @@ fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<() validate_nonempty("revision proposal change_reason", &proposal.change_reason) } +fn validate_candidate_application( + transaction: &Transaction<'_>, + subject_id: &str, + candidate: &SubjectStagingRecord, + target: &MemoryRevisionTarget, + draft: &MemoryDraft, +) -> Result<()> { + let Some(proposal) = &candidate.revision_proposal else { + return Ok(()); + }; + let MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + } = target + else { + return Err(SubjektivError::InvalidRecord( + "a revision proposal cannot create a different Memory".into(), + )); + }; + if memory_id != &proposal.memory_id || expected_revision != &proposal.expected_revision { + return Err(SubjektivError::InvalidRecord( + "candidate application target must exactly match revision proposal metadata".into(), + )); + } + let current = scoped_memory_in_connection(transaction, subject_id, memory_id)?; + if current.revision != *expected_revision { + return Err(SubjektivError::RevisionConflict { + memory_id: memory_id.clone(), + expected: *expected_revision, + actual: current.revision, + }); + } + let expected_state = match proposal.intent { + RevisionProposalIntent::Revise => current.state, + RevisionProposalIntent::Resolve => MemoryState::Resolved, + RevisionProposalIntent::Retract => MemoryState::Retracted, + RevisionProposalIntent::Reopen => MemoryState::Active, + }; + let intent_allowed = match proposal.intent { + RevisionProposalIntent::Revise => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Resolve => current.state == MemoryState::Active, + RevisionProposalIntent::Retract => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + }; + if !intent_allowed || draft.state != expected_state { + return Err(SubjektivError::InvalidStateTransition { + memory_id: memory_id.clone(), + from: current.state, + to: draft.state, + }); + } + if draft.kind != current.kind || draft.kind != candidate.kind { + return Err(SubjektivError::InvalidRecord( + "revision proposal application must preserve the target Memory kind".into(), + )); + } + if draft.change_reason != proposal.change_reason { + return Err(SubjektivError::InvalidRecord( + "revision proposal application must preserve change_reason".into(), + )); + } + Ok(()) +} + fn validate_staged_revision_proposal( transaction: &Transaction<'_>, record: &SubjectStagingRecord, @@ -2802,6 +3156,321 @@ mod tests { ); } + #[test] + fn atomic_candidate_decision_is_idempotent_and_preserves_proposal_metadata() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Original", "initial observation")) + .unwrap(); + let change_reason = "The remembered condition has ended"; + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-resolve-atomic", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + memory.revision, + change_reason, + )) + .unwrap(), + ) + .unwrap(); + let mut revised = draft("Resolved", change_reason); + revised.state = MemoryState::Resolved; + let request = CandidateDecisionRequest { + request_id: "decision-resolve-1".into(), + candidate_id: staged.id.clone(), + reason: "Accepted exact resolution proposal".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision: memory.revision, + }, + draft: revised, + }, + }; + + let first = store + .decide_candidate(&subject.id, request.clone()) + .unwrap(); + let retried = store.decide_candidate(&subject.id, request).unwrap(); + + assert_eq!(first.memory.as_ref().unwrap().revision, 2); + assert_eq!(retried.memory.as_ref().unwrap().revision, 2); + assert_eq!(first.resolution.id, retried.resolution.id); + assert_eq!(first.store_revision, 2); + assert!(first.surface_dirty); + assert_eq!( + first + .resolution + .candidate + .revision_proposal + .unwrap() + .change_reason, + change_reason + ); + assert_eq!( + store + .list_memory_revisions(&subject.id, &memory.id) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn candidate_decision_revalidates_stale_proposals_and_intent_transitions() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Lifecycle", "initial")) + .unwrap(); + let stale = store + .stage_candidate( + candidate(&subject.id, "candidate-stale-apply", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "stale correction", + )) + .unwrap(), + ) + .unwrap(); + store + .revise_memory(&subject.id, &memory.id, 1, draft("Advanced", "advance")) + .unwrap(); + let stale_result = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-stale".into(), + candidate_id: stale.id.clone(), + reason: "must not auto-rebase".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision: 1, + }, + draft: draft("Stale", "stale correction"), + }, + }, + ); + assert!(matches!( + stale_result, + Err(SubjektivError::RevisionConflict { + expected: 1, + actual: 2, + .. + }) + )); + assert!( + store + .staging_resolution(&subject.id, &stale.id) + .unwrap() + .is_none() + ); + + let apply_intent = |store: &SubjektivStore, + candidate_id: &str, + intent: RevisionProposalIntent, + expected_revision: u64, + state: MemoryState, + reason: &str| { + let staged = store + .stage_candidate( + candidate(&subject.id, candidate_id, "workspace-a") + .with_revision_proposal(proposal( + intent, + &memory.id, + expected_revision, + reason, + )) + .unwrap(), + ) + .unwrap(); + let mut next = draft(candidate_id, reason); + next.state = state; + store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: format!("decision-{candidate_id}"), + candidate_id: staged.id, + reason: format!("apply {candidate_id}"), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision, + }, + draft: next, + }, + }, + ) + .unwrap() + .memory + .unwrap() + }; + let resolved = apply_intent( + &store, + "resolve", + RevisionProposalIntent::Resolve, + 2, + MemoryState::Resolved, + "resolve reason", + ); + let reopened = apply_intent( + &store, + "reopen", + RevisionProposalIntent::Reopen, + resolved.revision, + MemoryState::Active, + "reopen reason", + ); + let retracted = apply_intent( + &store, + "retract", + RevisionProposalIntent::Retract, + reopened.revision, + MemoryState::Retracted, + "retract reason", + ); + assert_eq!(retracted.state, MemoryState::Retracted); + assert!(matches!( + store.stage_candidate( + candidate(&subject.id, "post-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + retracted.revision, + "cannot revive", + )) + .unwrap() + ), + Err(SubjektivError::InvalidStateTransition { .. }) + )); + } + + #[test] + fn candidate_decisions_reject_conflicts_and_roll_back_partial_writes() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other = store.create_subject(role()).unwrap(); + let staged = store + .stage_candidate(candidate(&subject.id, "candidate-rollback", "workspace-a")) + .unwrap(); + let foreign = store + .stage_candidate(candidate(&other.id, "candidate-foreign", "workspace-a")) + .unwrap(); + let mut invalid = draft("Would be written", "must roll back"); + invalid.derived_from.push(MemoryRevisionRef { + memory_id: "missing-memory".into(), + revision: 1, + }); + let failed = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-rollback".into(), + candidate_id: staged.id.clone(), + reason: "invalid derivation".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Create, + draft: invalid, + }, + }, + ); + assert!(matches!( + failed, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + assert!( + store + .staging_resolution(&subject.id, &staged.id) + .unwrap() + .is_none() + ); + assert!(store.list_memories(&subject.id).unwrap().is_empty()); + + let cross_subject = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-foreign".into(), + candidate_id: foreign.id, + reason: "must remain scoped".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::Invalid, + affected_memory: vec![], + }, + }, + ); + assert!(matches!( + cross_subject, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + + let closed = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-close".into(), + candidate_id: staged.id.clone(), + reason: "already represented elsewhere".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::AlreadyCovered, + affected_memory: vec![], + }, + }, + ) + .unwrap(); + assert!(!closed.surface_dirty); + assert!(closed.memory.is_none()); + for (suffix, action) in [ + ("discarded", StagingResolutionAction::Discarded), + ("invalid", StagingResolutionAction::Invalid), + ("duplicate", StagingResolutionAction::Duplicate), + ] { + let candidate_id = format!("candidate-{suffix}"); + store + .stage_candidate(candidate(&subject.id, &candidate_id, "workspace-a")) + .unwrap(); + let receipt = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: format!("decision-{suffix}"), + candidate_id, + reason: format!("specific {suffix} reason"), + decision: CandidateDecision::Close { + action, + affected_memory: vec![], + }, + }, + ) + .unwrap(); + assert_eq!(receipt.resolution.action, action); + assert!(!receipt.surface_dirty); + } + let conflicting_retry = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-close".into(), + candidate_id: staged.id, + reason: "different disposition".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::Discarded, + affected_memory: vec![], + }, + }, + ); + assert!(matches!( + conflicting_retry, + Err(SubjektivError::DecisionRequestConflict(_)) + )); + } + #[test] fn revision_proposals_enforce_scope_revision_and_state_transitions() { let temp = tempfile::tempdir().unwrap(); diff --git a/openapi/server-api.json b/openapi/server-api.json index 69ebe5b89..968d40110 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -14081,7 +14081,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f" + "x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9" }, "openapi": "3.1.0", "paths": { diff --git a/resources/profiles/subjektiv-memory-consolidation.dcdl b/resources/profiles/subjektiv-memory-consolidation.dcdl new file mode 100644 index 000000000..676610f7e --- /dev/null +++ b/resources/profiles/subjektiv-memory-consolidation.dcdl @@ -0,0 +1,17 @@ +import "./base.dcdl" // { + slug = "subjektiv-memory-consolidation"; + description = "Delegated subject Memory staging consolidation profile."; + scope = "workspace_read"; + model = { ref = "codex-oauth/gpt-5.6-luna"; }; + engine = { reasoning = "medium"; }; + + feature = { + task = { enabled = false; }; + memory = { enabled = true; staging_tools = true; }; + web = { enabled = false; }; + sub_worker = { enabled = false; }; + worker = { enabled = false; }; + objective = { enabled = false; }; + ticket = { enabled = false; thread = false; }; + }; +} diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index 7d61dcce9..271420d6c 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -23,6 +23,7 @@ internalFlowVerifierSystem = import "./internal/flow_verifier_system.md"; internalBackendJobSystem = import "./internal/backend_job_system.md"; internalTicketItemChecker = import "./internal/ticket_item_checker.md"; internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md"; +internalSubjektivMemoryConsolidationSystem = import "./internal/subjektiv_memory_consolidation_system.md"; internalMemoryExtractSystem = import "./internal/memory_extract_system.md"; internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md"; internalNotifyWrapper = import "./internal/notify_wrapper.md"; @@ -60,6 +61,7 @@ in backend_job_system = internalBackendJobSystem.content; ticket_item_checker = internalTicketItemChecker.content; memory_consolidation_system = internalMemoryConsolidationSystem.content; + subjektiv_memory_consolidation_system = internalSubjektivMemoryConsolidationSystem.content; memory_extract_system = internalMemoryExtractSystem.content; workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content; notify_wrapper = internalNotifyWrapper.content; diff --git a/resources/prompts/internal/subjektiv_memory_consolidation_system.md b/resources/prompts/internal/subjektiv_memory_consolidation_system.md new file mode 100644 index 000000000..b04cef77b --- /dev/null +++ b/resources/prompts/internal/subjektiv_memory_consolidation_system.md @@ -0,0 +1,41 @@ +# Memory staging consolidater + +You are the delegated internal Memory staging consolidater for one subject. + +Your job is to consume that subject's pending Memory candidates through tools. The host binds every tool call to the subject; never infer or provide a subject identifier yourself. + +Use this loop: + +1. Call `MemoryStagingList` to inspect pending candidates. +2. Pick one candidate and call `MemoryStagingRead` for the full immutable record, including any typed `revision_proposal`. +3. Use `SubjektivMemoryQuery`, `SubjektivMemoryRead`, and when useful `SubjektivMemoryListRevisions` to compare it with confirmed Memory. +4. Decide whether to create a Memory, revise/refine one exact current revision, resolve/retract/reopen according to typed proposal metadata, or reject the candidate. +5. Call `MemoryApplyCandidate` exactly once for every decision. Supply a stable `request_id`; retry the exact same request after an ambiguous transport failure. + +`MemoryApplyCandidate` atomically writes any new Memory revision, records concrete affected revision references, preserves the original candidate and provenance, and closes the pending candidate. A successful applied response is the only evidence that confirmed Memory changed. + +Rules: + +- A candidate is evidence, not authority. Do not promote derived/model claims to observations without warranted provenance. +- For a candidate with `revision_proposal`, use its `memory_id`, `expected_revision`, `intent`, and `change_reason`; do not infer a different target from claim prose. +- Proposal application must preserve `change_reason` exactly and use the state implied by `intent`: `resolve` -> `resolved`, `retract` -> `retracted`, `reopen` -> `active`, and `revise` keeps the current state. +- Never rebase a stale proposal. On `revision_conflict` or another typed conflict, leave it pending for later reread/reconciliation. +- Never revive a `retracted` Memory. New experience after retraction becomes a new Memory with an exact `derived_from` revision when justified. +- `resolved` -> `active` is allowed only for a valid `reopen` proposal. +- New or revised prose must remain concise and useful. Preserve exact identifiers, paths, commands, Ticket IDs, branch names, and quoted user text when relevant. +- `source_candidate_ids` are host-derived from the applied candidate. Do not attempt to author them. +- `derived_from` must contain only exact existing Memory revision refs returned by reads. +- Applied responses return affected refs with `{memory_id, revision, operation}`. `duplicate` and `already_covered` may reference exact existing revisions; `discarded` and `invalid` require a concrete reason. +- Surface/snapshot generation is deferred. `surface_dirty: true` reports that a later process should rebuild it; never treat surface generation failure as failure of an already returned committed decision. +- An empty model response or transport failure is not a candidate disposition. Only a successful `MemoryApplyCandidate` response closes a candidate. + +Valid non-applied close actions are `discarded`, `invalid`, `duplicate`, and `already_covered`. Always provide a specific reason. + +## Language + +- `language`: `{{language}}` +- Write confirmed Memory prose in this language. +- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is. +- If the configured language is unclear, use English. + +Do not create Knowledge, Skill, Ticket, documentation, child Worker, or filesystem records. Use only the supplied Memory tools. diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 77a81d918..7e782b48b 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) +// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 256443914..3ca4ddb28 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) +// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 6bc86ddb3..79b0d1bf0 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) +// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index c6e12177f..e687625f0 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) +// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index b9d390390..0824a0267 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:cacb143d65459cf22331ee637382620ae2a3462dbee51c0e5caf56752895105f) +// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From ba98a6afe6459621b0bbfe3c948a7db4639da607 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 11:13:11 +0900 Subject: [PATCH 12/31] fix: harden subject memory consolidation --- crates/manifest/src/profile.rs | 49 ++++ crates/worker/src/feature/builtin/memory.rs | 66 +++++ .../src/feature/builtin/memory_lifecycle.rs | 34 ++- crates/workspace-server/src/hosts.rs | 3 +- crates/workspace-server/src/server.rs | 240 +++++++++++++++--- .../subjektiv-memory-consolidation.dcdl | 32 ++- 6 files changed, 387 insertions(+), 37 deletions(-) diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index c504ce154..78d91b1ff 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1325,6 +1325,55 @@ mod tests { } } + #[test] + fn subjektiv_memory_consolidation_profile_is_lifecycle_and_capability_isolated() { + let tmp = TempDir::new().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named( + ProfileRegistrySource::Builtin, + "subjektiv-memory-consolidation", + ), + ProfileResolveOptions::with_worker_name("subject-consolidator"), + ) + .unwrap(); + let manifest = resolved.manifest; + let feature = &manifest.feature; + + assert!(feature.memory.profile.enabled); + assert!(feature.memory.profile.staging_tools); + assert!(!feature.memory.profile.resident.inject_summary); + assert!(!feature.memory.profile.extraction.enabled); + assert!(!feature.memory.profile.consolidation.request_enabled); + assert!(!feature.subjektiv.profile.enabled); + assert!(!feature.task.enabled); + assert!(!feature.web.enabled); + assert!(!feature.image.enabled); + assert!(!feature.sub_worker.enabled); + assert!(!feature.flow.enabled); + assert!(!feature.worker.enabled); + assert!(!feature.worker.direct_spawn); + assert!(!feature.workspace_worker_discovery.enabled); + assert!(!feature.objective.enabled); + assert!(!feature.manage_workdir.enabled); + assert!(!feature.ticket.enabled); + assert!(!feature.ticket.authoring); + assert!(!feature.ticket.thread); + assert!(!feature.ticket.intake); + assert!(!feature.ticket.workflow); + assert!(!feature.merge_request.show); + assert!(!feature.merge_request.open); + assert!(!feature.merge_request.review); + assert!(!feature.merge_request.readiness_check); + assert!(!feature.merge_request.complete); + assert!(!feature.orchestration.enabled); + assert_eq!( + manifest.web.as_ref().and_then(|web| web.enabled), + Some(false) + ); + } + #[test] fn profile_rejects_workspace_memory_snapshot_authority_fields() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index f3c707ff4..cafe6545d 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -781,6 +781,39 @@ mod tests { )) } + fn repository_error_client(status: u16, code: &str) -> Arc { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = serde_json::json!({ + "error": "request rejected", + "message": format!("{code}: retry after reread"), + "diagnostics": [{ + "code": code, + "severity": "error", + "message": format!("{code}: retry after reread"), + }], + }) + .to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + let response = format!( + "HTTP/1.1 {status} rejected\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + Arc::new(crate::worker::TestWorkspaceHttpClient::new( + "workspace", + format!("http://{addr}"), + )) + } + fn tool_names(definitions: Vec) -> Vec { let mut names = definitions .into_iter() @@ -991,6 +1024,39 @@ mod tests { assert!(names.contains(&"MemoryStagingClose".to_string())); } + #[tokio::test] + async fn subjektiv_candidate_conflicts_survive_workspace_http_transport() { + let input = serde_json::json!({ + "request_id": "decision-request-1", + "candidate_id": "candidate-1", + "reason": "candidate is already covered", + "decision": { + "kind": "close", + "action": "already_covered", + "affected_memory": [], + }, + }) + .to_string(); + for (status, code) in [ + (409, "candidate_decision_conflict"), + (403, "subject_scope_mismatch"), + ] { + let tool = SubjectConsolidationTool { + client: repository_error_client(status, code), + operation: SubjectConsolidationOperation::DecideCandidate, + }; + let output = tool + .execute(&input, ToolExecutionContext::direct()) + .await + .expect("typed conflicts are model-visible retry output"); + let content: serde_json::Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(content["status"], "error"); + assert_eq!(content["error"]["code"], code); + assert!(output.summary.contains("requires reread")); + } + } + #[test] fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() { let tools = workspace_http_subjektiv_consolidation_tools(test_client()); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index da1bb9fe6..3bdfc9759 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -79,6 +79,15 @@ struct MemoryLifecycleTask { event_tx: Option>, } +fn memory_lifecycle_requested( + lifecycle_enabled: bool, + config: &manifest::ResolvedMemoryFeatureConfig, +) -> bool { + lifecycle_enabled + && config.profile.enabled + && (config.profile.extraction.enabled || config.profile.consolidation.request_enabled) +} + impl MemoryLifecycleFeature { #[allow(clippy::too_many_arguments)] pub(crate) fn from_resolved_config( @@ -93,10 +102,7 @@ impl MemoryLifecycleFeature { workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled - || !config.profile.enabled - || (!config.profile.extraction.enabled && !config.profile.consolidation.request_enabled) - { + if !memory_lifecycle_requested(lifecycle_enabled, &config) { return Ok(None); } config @@ -1365,6 +1371,26 @@ permission = "write" config } + #[test] + fn resolved_subjektiv_consolidation_profile_does_not_install_global_memory_lifecycle() { + let workspace = tempfile::tempdir().unwrap(); + let resolved = manifest::ProfileResolver::new() + .with_workspace_base(workspace.path()) + .resolve( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + "subjektiv-memory-consolidation", + ), + manifest::ProfileResolveOptions::with_worker_name("subject-consolidator"), + ) + .unwrap(); + + assert!(!memory_lifecycle_requested( + true, + &resolved.manifest.feature.memory + )); + } + fn test_task( capture: CommittedSessionCapture, client: Box, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index aa903a20f..c197071cc 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -5370,6 +5370,7 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option { const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; pub(crate) const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; +pub(crate) const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:"; const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator"; pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator"; @@ -5377,7 +5378,7 @@ pub(crate) fn is_reserved_internal_worker_singleton_key(key: &str) -> bool { matches!( key, MEMORY_CONSOLIDATION_SINGLETON_KEY | WORKSPACE_ORCHESTRATOR_SINGLETON_KEY - ) + ) || key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX) } struct WorkerDisplayMetadata { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd8e703fd..f07ff40ff 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -16544,7 +16544,9 @@ async fn scoped_memory_backend_operation( } const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; -const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:"; +const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = + crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; +const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) @@ -16591,6 +16593,38 @@ fn subjektiv_singleton_key(subject_id: &str) -> ApiResult { Ok(key) } +fn is_dedicated_subjektiv_consolidation_worker( + worker: &InternalWorkerSummary, + workspace_id: &str, + singleton_key: &str, +) -> bool { + let profile_matches = worker.profile.as_deref().is_some_and(|profile| { + profile.strip_prefix("builtin:").unwrap_or(profile) + == SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE + }); + let profile_tag = format!("profile:{SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE}"); + worker.workspace.workspace_id.as_deref() == Some(workspace_id) + && worker.singleton_key.as_deref() == Some(singleton_key) + && singleton_key.starts_with(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX) + && profile_matches + && worker.tags.iter().any(|tag| tag == &profile_tag) +} + +fn require_dedicated_subjektiv_consolidation_worker( + worker: &InternalWorkerSummary, + workspace_id: &str, + singleton_key: &str, +) -> ApiResult<()> { + if is_dedicated_subjektiv_consolidation_worker(worker, workspace_id, singleton_key) { + Ok(()) + } else { + Err(Error::WorkspacePermissionDenied( + "subject Memory consolidation requires a Backend-managed dedicated Worker".to_string(), + ) + .into()) + } +} + fn subjektiv_subject_scope( api: &WorkspaceApi, workspace_id: &str, @@ -16616,6 +16650,14 @@ fn subjektiv_subject_scope( ) })?; let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?; + if authority == SubjektivWorkerAuthority::Consolidation { + let mut summary = api + .runtime + .worker(&worker) + .map_err(|error| error.into_error())?; + summary.singleton_key = Some(lease.key.clone()); + require_dedicated_subjektiv_consolidation_worker(&summary, workspace_id, &lease.key)?; + } Ok((subject_id.to_string(), worker, authority)) } @@ -18187,7 +18229,6 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { } const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; -const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; async fn scoped_subjektiv_memory_consolidation( @@ -18324,6 +18365,11 @@ fn try_reuse_subject_consolidation_worker( .worker(&owner.worker) .map_err(|error| error.into_error())?; worker.singleton_key = Some(owner.key); + require_dedicated_subjektiv_consolidation_worker( + &worker, + &api.config.workspace_id, + singleton_key, + )?; if worker.state != "idle" { return Ok(Some(MemoryConsolidationOutput { status: "skipped_existing_not_idle".into(), @@ -29621,25 +29667,32 @@ impl From for ApiError { severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(&ticket_error.to_string()), }], - Error::RepositoryConflict(message) => subjektiv_conflict_code(message) - .map(|code| { - vec![RuntimeDiagnostic { - code: code.to_string(), - severity: HostDiagnosticSeverity::Error, - message: sanitize_backend_error(message), - }] - }) - .unwrap_or_default(), + Error::RepositoryConflict(message) | Error::WorkspacePermissionDenied(message) => { + subjektiv_diagnostic_code(message) + .map(|code| { + vec![RuntimeDiagnostic { + code: code.to_string(), + severity: HostDiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }] + }) + .unwrap_or_default() + } _ => Vec::new(), }; Self { error, diagnostics } } } -fn subjektiv_conflict_code(message: &str) -> Option<&'static str> { - ["revision_conflict", "stale_cursor"] - .into_iter() - .find(|code| message.starts_with(code)) +fn subjektiv_diagnostic_code(message: &str) -> Option<&'static str> { + [ + "revision_conflict", + "stale_cursor", + "candidate_decision_conflict", + "subject_scope_mismatch", + ] + .into_iter() + .find(|code| message.starts_with(code)) } impl ApiError { @@ -43413,31 +43466,42 @@ mod tests { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; - let request = |requested_worker_name: &str, - intent: server_api::RuntimeWorkerSpawnIntent, - profile: &str| server_api::RuntimeWorkerSpawnRequest { - requested_worker_name: Some(requested_worker_name.to_string()), - intent, - singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()), - acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 0, - }, - profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()), - ticket_assignment: None, - initial_submit: Vec::new(), - workdir_attachment_requests: Vec::new(), - }; + let request = + |requested_worker_name: &str, + intent: server_api::RuntimeWorkerSpawnIntent, + profile: &str, + singleton_key: &str| server_api::RuntimeWorkerSpawnRequest { + requested_worker_name: Some(requested_worker_name.to_string()), + intent, + singleton_key: Some(singleton_key.to_string()), + acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: server_api::RuntimeProfileSelector::Builtin(profile.to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + }; + let subject_consolidation_key = "subjektiv-consolidation:subject-forged"; for candidate in [ request( "Generic key squatter", server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion, "builtin:companion", + crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY, ), request( crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY, server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator, "builtin:orchestrator", + crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY, + ), + request( + SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE, + server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator, + "builtin:subjektiv-memory-consolidation", + subject_consolidation_key, ), ] { let error = scoped_create_runtime_worker( @@ -43461,6 +43525,12 @@ mod tests { .unwrap() .is_none() ); + assert!( + api.store + .current_worker_singleton_owner(TEST_WORKSPACE_ID, subject_consolidation_key) + .unwrap() + .is_none() + ); assert!(find_workspace_orchestrator(&api).is_none()); } @@ -51157,6 +51227,118 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); serde_json::from_slice(&bytes).unwrap() } + #[tokio::test] + async fn subjektiv_consolidation_scope_rejects_non_dedicated_singleton_owner() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let spawn = |profile: &str, key: &str| WorkerSpawnRequest { + requested_worker_name: Some(profile.to_string()), + singleton_key: Some(key.to_string()), + intent: WorkerSpawnIntent::WorkspaceOrchestrator, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: ProfileSelector::Builtin(profile.to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_control_operation: None, + }; + + let dedicated_key = "subjektiv-consolidation:dedicated-subject"; + let dedicated = api + .spawn_workspace_worker( + EMBEDDED_WORKER_RUNTIME_ID, + spawn(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE, dedicated_key), + ) + .unwrap() + .worker + .unwrap(); + assert!(is_dedicated_subjektiv_consolidation_worker( + &dedicated, + TEST_WORKSPACE_ID, + dedicated_key + )); + let dedicated_context = server_api::ServerRequestContext { + actor: None, + worker_source: None, + runtime_source: Some(server_api::ServerRuntimeSource { + runtime_id: dedicated.worker.runtime_id.clone(), + worker_id: Some(dedicated.worker.worker_id.clone()), + }), + origin: None, + transport_headers: Vec::new(), + }; + assert_eq!( + subjektiv_subject_scope(&api, TEST_WORKSPACE_ID, &dedicated_context) + .unwrap() + .2, + SubjektivWorkerAuthority::Consolidation + ); + + let forged_key = "subjektiv-consolidation:forged-subject"; + let forged = api + .spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn("companion", forged_key)) + .unwrap() + .worker + .unwrap(); + let forged_context = server_api::ServerRequestContext { + actor: None, + worker_source: None, + runtime_source: Some(server_api::ServerRuntimeSource { + runtime_id: forged.worker.runtime_id.clone(), + worker_id: Some(forged.worker.worker_id.clone()), + }), + origin: None, + transport_headers: Vec::new(), + }; + let error = subjektiv_subject_scope(&api, TEST_WORKSPACE_ID, &forged_context).unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + assert!( + try_reuse_subject_consolidation_worker( + &api, + forged_key, + "process pending subject Memory", + 1, + 1, + ) + .is_err() + ); + } + + #[test] + fn subjektiv_http_errors_emit_typed_retry_diagnostics() { + for (error, code, status) in [ + ( + Error::RepositoryConflict( + "candidate_decision_conflict: candidate already resolved".to_string(), + ), + "candidate_decision_conflict", + StatusCode::CONFLICT, + ), + ( + Error::WorkspacePermissionDenied( + "subject_scope_mismatch: candidate belongs to another subject".to_string(), + ), + "subject_scope_mismatch", + StatusCode::FORBIDDEN, + ), + ] { + assert_eq!(api_error_status(&error), status); + let response = ApiError::from(error).into_repository_api_error(); + assert_eq!(response.diagnostics.len(), 1); + assert_eq!(response.diagnostics[0].code, code); + } + } + #[test] fn subjektiv_singleton_scope_is_explicit_and_opaque() { assert_eq!( diff --git a/resources/profiles/subjektiv-memory-consolidation.dcdl b/resources/profiles/subjektiv-memory-consolidation.dcdl index 676610f7e..f7fbba83e 100644 --- a/resources/profiles/subjektiv-memory-consolidation.dcdl +++ b/resources/profiles/subjektiv-memory-consolidation.dcdl @@ -4,14 +4,40 @@ import "./base.dcdl" // { scope = "workspace_read"; model = { ref = "codex-oauth/gpt-5.6-luna"; }; engine = { reasoning = "medium"; }; + web = { enabled = false; }; feature = { task = { enabled = false; }; - memory = { enabled = true; staging_tools = true; }; + memory = { + enabled = true; + staging_tools = true; + resident = { inject_summary = false; }; + extraction = { enabled = false; }; + consolidation = { request_enabled = false; }; + }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = false; }; + image = { enabled = false; }; sub_worker = { enabled = false; }; - worker = { enabled = false; }; + flow = { enabled = false; }; + worker = { enabled = false; direct_spawn = false; }; + workspace_worker_discovery = { enabled = false; }; objective = { enabled = false; }; - ticket = { enabled = false; thread = false; }; + manage_workdir = { enabled = false; }; + ticket = { + enabled = false; + authoring = false; + thread = false; + intake = false; + workflow = false; + }; + merge_request = { + show = false; + open = false; + review = false; + readiness_check = false; + complete = false; + }; + orchestration = { enabled = false; }; }; } From c6313101922b8cb7a519fa3adbbaa2a87228b09f Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 11:13:38 +0900 Subject: [PATCH 13/31] Scope durable Worker identity by Runtime --- crates/workspace-server/src/latest_schema.sql | 28 +- crates/workspace-server/src/store.rs | 360 +++++++++++++++++- 2 files changed, 367 insertions(+), 21 deletions(-) diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 1ba828b75..ab2178ed3 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -753,11 +753,11 @@ CREATE TABLE "worker_control_grants" ( created_at TEXT NOT NULL, revoked_at TEXT, PRIMARY KEY (workspace_id, grant_id), - UNIQUE (workspace_id, controller_worker_id, operation_id), - FOREIGN KEY (workspace_id, controller_worker_id) - REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, subject_worker_id) - REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE + UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE ); CREATE TABLE worker_create_reservations ( workspace_id TEXT NOT NULL, @@ -832,7 +832,7 @@ CREATE TABLE "worker_registry" ( diagnostics_ref TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, worker_id), + PRIMARY KEY (workspace_id, runtime_id, worker_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); CREATE TABLE backend_jobs ( @@ -916,8 +916,8 @@ CREATE TABLE worker_registry_observations ( worker_json TEXT, observed_at TEXT NOT NULL, PRIMARY KEY (workspace_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE ); CREATE TABLE worker_registry_projection_removals ( workspace_id TEXT NOT NULL, @@ -982,9 +982,9 @@ CREATE TABLE "worker_workdir_links" ( alias TEXT NOT NULL, linked_at TEXT NOT NULL, unlinked_at TEXT, - PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), - FOREIGN KEY (workspace_id, worker_id) - REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE, + PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE ); @@ -1201,11 +1201,11 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id ON worker_workdir_attachment_reservations(workspace_id, reservation_id); CREATE INDEX worker_control_grants_controller ON worker_control_grants( - workspace_id, controller_worker_id, revoked_at + workspace_id, controller_runtime_id, controller_worker_id, revoked_at ); CREATE INDEX worker_control_grants_subject ON worker_control_grants( - workspace_id, subject_worker_id, revoked_at + workspace_id, subject_runtime_id, subject_worker_id, revoked_at ); CREATE INDEX worker_create_reservations_worker ON worker_create_reservations(workspace_id, worker_id); @@ -1216,7 +1216,7 @@ CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique - ON worker_workdir_links(workspace_id, worker_id, alias) + ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) WHERE unlinked_at IS NULL; CREATE INDEX worker_workdir_links_workdir ON worker_workdir_links(workspace_id, workdir_id); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 4dfe1198f..d4b654f96 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 74; +const LATEST_SCHEMA_VERSION: i64 = 75; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -70,6 +70,8 @@ const WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME: &str = "durable Workspace Worker singleton ownership"; const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str = "ordered Worker projection subscriptions without revision reconciliation"; +const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str = + "Runtime-scoped durable Worker catalog identity"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -237,6 +239,11 @@ const MIGRATIONS: &[Migration] = &[ name: ORDERED_WORKER_PROJECTION_MIGRATION_NAME, apply: migrate_ordered_worker_projection_v73_to_v74, }, + Migration { + version: 75, + name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME, + apply: migrate_runtime_scoped_worker_identity_v74_to_v75, + }, ]; #[derive(Clone, Copy)] @@ -6772,8 +6779,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) - ON CONFLICT(workspace_id, worker_id) DO UPDATE SET - runtime_id = excluded.runtime_id, + ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET display_name = excluded.display_name, profile = excluded.profile, retention_state = CASE @@ -7201,6 +7207,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT ( workspace_id, + controller_runtime_id, controller_worker_id, operation_id ) DO NOTHING"#, @@ -9035,7 +9042,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO worker_workdir_links ( workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) - ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, @@ -9179,7 +9186,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO worker_workdir_links ( workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) - ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, @@ -13783,6 +13790,239 @@ fn migrate_ordered_worker_projection_v73_to_v74(conn: &Connection) -> Result<()> Ok(()) } +fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 74 { + return Err(Error::Store(format!( + "expected schema version 74 before {RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME} migration, found {current}" + ))); + } + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + DROP INDEX worker_control_grants_controller; + DROP INDEX worker_control_grants_subject; + DROP INDEX worker_workdir_links_active_workdir_unique; + DROP INDEX worker_workdir_links_active_alias_unique; + DROP INDEX worker_workdir_links_workdir; + DROP INDEX idx_worker_registry_workspace_runtime_worker; + DROP INDEX worker_registry_runtime; + + ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v74; + ALTER TABLE worker_control_grants RENAME TO worker_control_grants_v74; + ALTER TABLE worker_workdir_links RENAME TO worker_workdir_links_v74; + ALTER TABLE worker_registry RENAME TO worker_registry_v74; + + CREATE TABLE worker_registry ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry + SELECT * FROM worker_registry_v74; + + CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_registry_observations + SELECT * FROM worker_registry_observations_v74; + + CREATE TABLE worker_control_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + relation TEXT NOT NULL, + origin TEXT NOT NULL, + permissions_json TEXT NOT NULL, + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); + INSERT INTO worker_control_grants + SELECT * FROM worker_control_grants_v74; + + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + capabilities TEXT NOT NULL CHECK (capabilities IN ( + 'all', 'read_only', 'read_write', 'command_only', 'read_command' + )), + PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links + SELECT * FROM worker_workdir_links_v74; + + DROP TABLE worker_registry_observations_v74; + DROP TABLE worker_control_grants_v74; + DROP TABLE worker_workdir_links_v74; + DROP TABLE worker_registry_v74; + + CREATE INDEX worker_control_grants_controller ON worker_control_grants( + workspace_id, controller_runtime_id, controller_worker_id, revoked_at + ); + CREATE INDEX worker_control_grants_subject ON worker_control_grants( + workspace_id, subject_runtime_id, subject_worker_id, revoked_at + ); + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) + WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE INDEX worker_registry_runtime + ON worker_registry(workspace_id, runtime_id, worker_id); + + CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_registry FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_registry FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![75_i64, RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) + })(); + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (result, restore_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => { + let violations = conn + .prepare("PRAGMA foreign_key_check")? + .query_map([], |_| Ok(()))? + .count(); + if violations != 0 { + return Err(Error::Store(format!( + "Runtime-scoped Worker identity migration left {violations} foreign key violation(s)" + ))); + } + Ok(()) + } + } +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -14634,6 +14874,8 @@ mod tests { retention_state, created_at, updated_at ) VALUES ('workspace-a', 'worker-a', 'runtime-a', 'Worker A', NULL, 'normal', '1', '1'); + CREATE UNIQUE INDEX worker_registry_v74_workspace_worker + ON worker_registry(workspace_id, worker_id); ALTER TABLE worker_registry_observations RENAME TO worker_registry_observations_v74; @@ -14746,6 +14988,8 @@ mod tests { DROP TABLE typed_ticket_targets; ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + CREATE UNIQUE INDEX worker_registry_v74_workspace_worker + ON worker_registry(workspace_id, worker_id); ALTER TABLE worker_workdir_links DROP COLUMN capabilities; DELETE FROM ticket_schema_migrations; INSERT INTO ticket_schema_migrations(version, name, applied_at) @@ -15661,6 +15905,8 @@ mod tests { DROP TABLE typed_ticket_targets; ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + CREATE UNIQUE INDEX worker_registry_v74_workspace_worker + ON worker_registry(workspace_id, worker_id); DROP INDEX worker_workdir_links_active_workdir_unique; DROP INDEX worker_workdir_links_active_alias_unique; DROP INDEX worker_workdir_links_workdir; @@ -16182,6 +16428,10 @@ mod tests { version: 74, name: ORDERED_WORKER_PROJECTION_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 75, + name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), + }, ] ); @@ -16260,6 +16510,10 @@ mod tests { (72, BACKEND_JOB_DISPATCH_QUEUE_MIGRATION_NAME.to_string()), (73, WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME.to_string()), (74, ORDERED_WORKER_PROJECTION_MIGRATION_NAME.to_string()), + ( + 75, + RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16546,7 +16800,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74 + 73, 74, 75 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -16555,7 +16809,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 25); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -19926,6 +20180,98 @@ INSERT INTO worker_registry ( ); } + #[tokio::test] + async fn worker_projection_preserves_equal_local_ids_from_distinct_runtimes() { + let temp = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "local-dev".to_string(), + owner_account_id: "owner-account".to_string(), + display_name: "Local Dev".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let record = |runtime_id: &str, display_name: &str| WorkerRegistryRecord { + workspace_id: "local-dev".to_string(), + worker: RuntimeWorkerRef::new(runtime_id, "shared-local-id"), + display_name: display_name.to_string(), + profile: None, + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + let runtime_a = record("runtime-a", "Runtime A Worker"); + let runtime_b = record("runtime-b", "Runtime B Worker"); + store.upsert_worker_registry(&runtime_a).unwrap(); + store.upsert_worker_registry(&runtime_b).unwrap(); + + for record in [&runtime_a, &runtime_b] { + let observed = SubscriptionWorker { + worker_id: SubscriptionWorkerId::new("shared-local-id").unwrap(), + runtime_id: Some(record.worker.runtime_id.clone()), + resource_key: None, + availability: SubscriptionWorkerAvailability::Observed, + worker_state: None, + state: protocol::subscription::SubscriptionWorkerState::Running, + has_running_internal_workers: false, + workspace_id: Some("local-dev".to_string()), + display_name: Some(record.display_name.clone()), + profile: None, + job: None, + workdir_attachments: Vec::new(), + }; + store + .apply_worker_registry_observation( + "local-dev", + record.worker.runtime_id.as_str(), + &observed, + "2", + ) + .unwrap(); + } + + let snapshot = store + .worker_registry_projection_snapshot("local-dev", 10) + .unwrap(); + assert_eq!(snapshot.len(), 2); + assert!( + snapshot + .iter() + .any(|row| row.registry.worker == runtime_a.worker) + ); + assert!( + snapshot + .iter() + .any(|row| row.registry.worker == runtime_b.worker) + ); + + store + .delete_worker_registry("local-dev", &runtime_a.worker) + .unwrap(); + assert!( + store + .get_worker_registry("local-dev", &runtime_a.worker) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_worker_registry("local-dev", &runtime_b.worker) + .unwrap() + .unwrap() + .display_name, + "Runtime B Worker" + ); + } + #[tokio::test] async fn worker_projection_reconciles_snapshots_and_preserves_unavailable_lifecycle() { let temp = tempfile::tempdir().unwrap(); From 16fa3f14c912857187d2e1ce4261a40b17b7109e Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 11:31:46 +0900 Subject: [PATCH 14/31] Scope Worker resource keys by Runtime --- crates/workspace-server/src/authority.rs | 8 +- crates/workspace-server/src/server.rs | 87 ++++--- crates/workspace-server/src/store.rs | 280 ++++++++++++++++++++--- 3 files changed, 303 insertions(+), 72 deletions(-) diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 3ef844dbc..8a60ffe40 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -820,11 +820,9 @@ impl SqliteWorkspaceAuthority { .map(|worker| (assignment, worker)) }) .map(|(assignment, worker)| { - let worker_resource_key = self.store.resource_key( - &self.workspace_id, - WorkspaceResourceKind::Worker, - &worker.worker_id, - )?; + let worker_resource_key = self + .store + .worker_resource_key(&self.workspace_id, &worker)?; Ok::<_, Error>(TicketAssignmentSummary { assignment_id: assignment.assignment_id.clone(), runtime_id: worker.runtime_id, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a5504d863..b6d072a34 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -18823,13 +18823,9 @@ async fn scoped_get_workspace_worker( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let worker_id = api + let worker_ref = api .store - .resolve_resource_reference( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - &path.worker_ref, - )? + .resolve_worker_resource_reference(&api.config.workspace_id, &path.worker_ref)? .ok_or_else(|| Error::UnknownWorker { worker: RuntimeWorkerRef::new("unknown", &path.worker_ref), })?; @@ -18837,14 +18833,11 @@ async fn scoped_get_workspace_worker( workers .items .into_iter() - .find(|worker| worker.worker_id == worker_id) - .map(Json) - .ok_or_else(|| { - Error::UnknownWorker { - worker: RuntimeWorkerRef::new("unknown", worker_id), - } - .into() + .find(|worker| { + worker.runtime_id == worker_ref.runtime_id && worker.worker_id == worker_ref.worker_id }) + .map(Json) + .ok_or_else(|| Error::UnknownWorker { worker: worker_ref }.into()) } async fn discover_workspace_workers( @@ -24887,7 +24880,7 @@ fn browser_worker_response_from_summary( let workspace_id = api.workspace_id().to_string(); let resource_key = api .store - .resource_key(&workspace_id, WorkspaceResourceKind::Worker, &worker_id)? + .worker_resource_key(&workspace_id, &worker.worker)? .ok_or_else(|| { Error::Store(format!( "Workspace Worker `{worker_id}` has no resource key after registration" @@ -25219,17 +25212,20 @@ fn resolve_workspace_worker_reference( runtime_id: &str, reference: &str, ) -> ApiResult { - let worker_id = api + let direct = RuntimeWorkerRef::new(runtime_id, reference); + if let Some(record) = api .store - .resolve_resource_reference( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - reference, - )? + .get_worker_registry(&api.config.workspace_id, &direct)? + { + return Ok(record.worker); + } + let worker = api + .store + .resolve_worker_resource_reference(&api.config.workspace_id, reference)? + .filter(|worker| worker.runtime_id == runtime_id) .ok_or_else(|| Error::UnknownWorker { worker: RuntimeWorkerRef::new(runtime_id, reference), })?; - let worker = RuntimeWorkerRef::new(runtime_id, worker_id); let record = api .store .get_worker_registry(&api.config.workspace_id, &worker)? @@ -26645,11 +26641,7 @@ fn project_workspace_worker_with_attachments( } let resource_key = api .store - .resource_key( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - &summary.worker.worker_id, - )? + .worker_resource_key(&api.config.workspace_id, &summary.worker)? .ok_or_else(|| { Error::Store(format!( "Workspace Worker `{}` has no resource key", @@ -31801,6 +31793,44 @@ mod tests { assert!(!href.contains("/runtimes/")); } + #[tokio::test] + async fn workspace_worker_resource_keys_resolve_equal_local_ids_independently() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + let runtime_a = RuntimeWorkerRef::new("runtime-a", "shared-local-id"); + let runtime_b = RuntimeWorkerRef::new("runtime-b", "shared-local-id"); + seed_worker_source_member(&api, &runtime_a.runtime_id, &runtime_a.worker_id); + seed_worker_source_member(&api, &runtime_b.runtime_id, &runtime_b.worker_id); + let runtime_a_key = api + .store + .worker_resource_key(TEST_WORKSPACE_ID, &runtime_a) + .unwrap() + .unwrap(); + let runtime_b_key = api + .store + .worker_resource_key(TEST_WORKSPACE_ID, &runtime_b) + .unwrap() + .unwrap(); + assert_ne!(runtime_a_key, runtime_b_key); + + for (resource_key, expected) in [ + (runtime_a_key, runtime_a.clone()), + (runtime_b_key, runtime_b.clone()), + ] { + let Json(worker) = scoped_get_workspace_worker( + State(api.clone()), + AxumPath(ScopedWorkspaceWorkerReferencePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker_ref: resource_key, + }), + ) + .await + .unwrap(); + assert_eq!(worker.runtime_id, expected.runtime_id); + assert_eq!(worker.worker_id, expected.worker_id); + } + } + #[test] fn runtime_resource_fetch_bypasses_workspace_mutation_lock() { assert!(!workspace_request_requires_mutation_lock( @@ -46879,10 +46909,9 @@ mod tests { seed_worker_source_member(&api, "runtime-test", "worker-target"); let target_key = api .store - .resource_key( + .worker_resource_key( TEST_WORKSPACE_ID, - WorkspaceResourceKind::Worker, - "worker-target", + &RuntimeWorkerRef::new("runtime-test", "worker-target"), ) .unwrap() .unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index d4b654f96..e0e182ee7 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1093,6 +1093,16 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { kind: WorkspaceResourceKind, reference: &str, ) -> Result>; + fn worker_resource_key( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result>; + fn resolve_worker_resource_reference( + &self, + workspace_id: &str, + reference: &str, + ) -> Result>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; async fn update_workspace_display_name( @@ -2352,11 +2362,13 @@ impl SqliteWorkspaceStore { ))); } }; + let worker_resource_id = + worker_resource_id(&RuntimeWorkerRef::new(runtime_id, worker_id_text.clone())); allocate_resource_key( &tx, workspace_id, WorkspaceResourceKind::Worker, - &worker_id_text, + &worker_resource_id, &now, )?; tx.execute( @@ -2570,8 +2582,8 @@ impl SqliteWorkspaceStore { let registry_exists = tx .query_row( "SELECT 1 FROM worker_registry \ - WHERE workspace_id = ?1 AND worker_id = ?2 LIMIT 1", - params![workspace_id, worker_id.to_string()], + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 LIMIT 1", + params![workspace_id, runtime_id, worker_id.to_string()], |_| Ok(()), ) .optional()? @@ -2603,13 +2615,17 @@ impl SqliteWorkspaceStore { ))); } } + let resource_id = worker_resource_id(&RuntimeWorkerRef::new( + runtime_id, + worker_id.to_string(), + )); tx.execute( "DELETE FROM workspace_resource_keys \ WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3", params![ workspace_id, WorkspaceResourceKind::Worker.as_str(), - worker_id.to_string() + resource_id ], )?; tx.commit()?; @@ -2624,15 +2640,15 @@ impl SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let reservation_state = tx + let reservation = tx .query_row( - "SELECT state FROM worker_create_reservations \ + "SELECT state, runtime_id FROM worker_create_reservations \ WHERE workspace_id = ?1 AND worker_id = ?2", params![workspace_id, worker_id.to_string()], - |row| row.get::<_, String>(0), + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ) .optional()?; - let Some(reservation_state) = reservation_state else { + let Some((reservation_state, runtime_id)) = reservation else { tx.commit()?; return Ok(false); }; @@ -2645,8 +2661,8 @@ impl SqliteWorkspaceStore { let registry_exists = tx .query_row( "SELECT 1 FROM worker_registry \ - WHERE workspace_id = ?1 AND worker_id = ?2 LIMIT 1", - params![workspace_id, worker_id.to_string()], + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 LIMIT 1", + params![workspace_id, runtime_id, worker_id.to_string()], |_| Ok(()), ) .optional()? @@ -2657,13 +2673,17 @@ impl SqliteWorkspaceStore { worker_id ))); } + let resource_id = worker_resource_id(&RuntimeWorkerRef::new( + runtime_id, + worker_id.to_string(), + )); let released = tx.execute( "DELETE FROM workspace_resource_keys \ WHERE workspace_id = ?1 AND resource_kind = ?2 AND resource_id = ?3", params![ workspace_id, WorkspaceResourceKind::Worker.as_str(), - worker_id.to_string() + resource_id ], )? > 0; tx.commit()?; @@ -3888,6 +3908,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { kind: WorkspaceResourceKind, resource_id: &str, ) -> Result> { + if kind == WorkspaceResourceKind::Worker { + return Err(Error::InvalidInput( + "Worker resource keys require a RuntimeWorkerRef".to_string(), + )); + } self.with_conn(|conn| { conn.query_row( "SELECT resource_key FROM workspace_resource_keys @@ -3906,6 +3931,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { kind: WorkspaceResourceKind, reference: &str, ) -> Result> { + if kind == WorkspaceResourceKind::Worker { + return Err(Error::InvalidInput( + "Worker references require a Runtime-scoped resolver".to_string(), + )); + } self.with_conn(|conn| { if let Some(resource_id) = conn .query_row( @@ -3939,6 +3969,67 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn worker_resource_key( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result> { + let resource_id = worker_resource_id(worker); + self.with_conn(|conn| { + conn.query_row( + "SELECT resource_key FROM workspace_resource_keys + WHERE workspace_id = ?1 AND resource_kind = 'worker' AND resource_id = ?2", + params![workspace_id, resource_id], + |row| row.get(0), + ) + .optional() + .map_err(Error::from) + }) + } + + fn resolve_worker_resource_reference( + &self, + workspace_id: &str, + reference: &str, + ) -> Result> { + self.with_conn(|conn| { + if let Some(resource_id) = conn + .query_row( + "SELECT resource_id FROM workspace_resource_keys + WHERE workspace_id = ?1 AND resource_kind = 'worker' AND resource_key = ?2", + params![workspace_id, reference], + |row| row.get::<_, String>(0), + ) + .optional()? + { + return parse_worker_resource_id(resource_id.as_str()) + .map(Some) + .ok_or_else(|| { + Error::Store(format!( + "Worker resource key `{reference}` has an invalid composite resource identity" + )) + }); + } + let mut statement = conn.prepare( + "SELECT runtime_id, worker_id FROM worker_registry + WHERE workspace_id = ?1 AND worker_id = ?2 + ORDER BY runtime_id LIMIT 2", + )?; + let identities = statement + .query_map(params![workspace_id, reference], |row| { + Ok(RuntimeWorkerRef::new( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + )) + })? + .collect::>>()?; + Ok(match identities.as_slice() { + [worker] => Some(worker.clone()), + _ => None, + }) + }) + } + async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -6814,11 +6905,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.updated_at, ], )?; + let resource_id = worker_resource_id(&record.worker); allocate_resource_key( &tx, &record.workspace_id, WorkspaceResourceKind::Worker, - &record.worker.worker_id, + &resource_id, &record.created_at, )?; let changed_workers = if changed == 0 { @@ -10464,10 +10556,11 @@ fn read_worker_registry_projection( let Some(registry) = registry else { return Ok(None); }; + let resource_id = worker_resource_id(worker); let resource_key = conn .query_row( "SELECT resource_key FROM workspace_resource_keys WHERE workspace_id = ?1 AND resource_kind = 'worker' AND resource_id = ?2", - params![workspace_id, worker.worker_id], + params![workspace_id, resource_id], |row| row.get(0), ) .optional()?; @@ -13843,6 +13936,37 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul INSERT INTO worker_registry SELECT * FROM worker_registry_v74; + UPDATE workspace_resource_keys AS resource + SET resource_id = COALESCE( + ( + SELECT hex(worker.runtime_id) || ':' || hex(worker.worker_id) + FROM worker_registry AS worker + WHERE worker.workspace_id = resource.workspace_id + AND worker.worker_id = resource.resource_id + LIMIT 1 + ), + ( + SELECT hex(reservation.runtime_id) || ':' || hex(reservation.worker_id) + FROM worker_create_reservations AS reservation + WHERE reservation.workspace_id = resource.workspace_id + AND reservation.worker_id = resource.resource_id + LIMIT 1 + ) + ) + WHERE resource.resource_kind = 'worker' + AND ( + EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = resource.workspace_id + AND worker.worker_id = resource.resource_id + ) + OR EXISTS ( + SELECT 1 FROM worker_create_reservations AS reservation + WHERE reservation.workspace_id = resource.workspace_id + AND reservation.worker_id = resource.resource_id + ) + ); + CREATE TABLE worker_registry_observations ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, @@ -14589,6 +14713,39 @@ fn current_schema_version(conn: &Connection) -> Result { .map_err(Error::from) } +fn worker_resource_id(worker: &RuntimeWorkerRef) -> String { + fn encode(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02X}")) + .collect() + } + format!( + "{}:{}", + encode(&worker.runtime_id), + encode(&worker.worker_id) + ) +} + +fn parse_worker_resource_id(resource_id: &str) -> Option { + fn decode(value: &str) -> Option { + if value.len() % 2 != 0 { + return None; + } + let bytes = (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok()) + .collect::>>()?; + String::from_utf8(bytes).ok() + } + let (runtime_id, worker_id) = resource_id.split_once(':')?; + Some(RuntimeWorkerRef::new( + decode(runtime_id)?, + decode(worker_id)?, + )) +} + fn allocate_resource_key( conn: &Connection, workspace_id: &str, @@ -14978,6 +15135,34 @@ mod tests { }) .unwrap(); assert_eq!(foreign_key_failures, 0); + + conn.execute( + "INSERT INTO workspace_resource_keys ( + workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at + ) VALUES ('workspace-a', 'worker', 'worker-a', 1, 'W-1', '1')", + [], + ) + .unwrap(); + migrate_runtime_scoped_worker_identity_v74_to_v75(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 75); + let worker = RuntimeWorkerRef::new("runtime-a", "worker-a"); + assert_eq!( + conn.query_row( + "SELECT resource_id FROM workspace_resource_keys + WHERE workspace_id = 'workspace-a' AND resource_key = 'W-1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + worker_resource_id(&worker) + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); } fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { @@ -18322,20 +18507,16 @@ mod tests { ); assert_eq!( store - .resource_key( - "workspace-a", - WorkspaceResourceKind::Worker, - &reserved.worker_id.to_string() - ) + .worker_resource_key("workspace-a", &reserved_worker) .unwrap() .as_deref(), Some("W-1") ); assert_eq!( store - .resolve_resource_reference("workspace-a", WorkspaceResourceKind::Worker, "W-1") + .resolve_worker_resource_reference("workspace-a", "W-1") .unwrap(), - Some(reserved.worker_id.to_string()) + Some(reserved_worker.clone()) ); let second = store .reserve_worker_create( @@ -18350,10 +18531,9 @@ mod tests { assert_eq!(second.memory_settings.settings_revision, 2); assert_eq!( store - .resource_key( + .worker_resource_key( "workspace-a", - WorkspaceResourceKind::Worker, - &second.worker_id.to_string() + &RuntimeWorkerRef::new("arcadia", second.worker_id.to_string()), ) .unwrap() .as_deref(), @@ -18385,17 +18565,16 @@ mod tests { ); assert_eq!( store - .resource_key( + .worker_resource_key( "workspace-a", - WorkspaceResourceKind::Worker, - &second.worker_id.to_string() + &RuntimeWorkerRef::new("arcadia", second.worker_id.to_string()), ) .unwrap(), None ); assert_eq!( store - .resolve_resource_reference("workspace-a", WorkspaceResourceKind::Worker, "W-2") + .resolve_worker_resource_reference("workspace-a", "W-2") .unwrap(), None ); @@ -18487,11 +18666,7 @@ mod tests { .unwrap(); assert_eq!( store - .resource_key( - "workspace-a", - WorkspaceResourceKind::Worker, - &reserved.worker_id.to_string(), - ) + .worker_resource_key("workspace-a", &reserved_worker) .unwrap(), None ); @@ -19957,11 +20132,7 @@ INSERT INTO worker_registry ( store.upsert_worker_registry(&runtime_sync_worker).unwrap(); assert_eq!( store - .resource_key( - "local-dev", - WorkspaceResourceKind::Worker, - &worker.worker.worker_id, - ) + .worker_resource_key("local-dev", &worker.worker) .unwrap() .as_deref(), Some("W-1") @@ -20252,6 +20423,39 @@ INSERT INTO worker_registry ( .iter() .any(|row| row.registry.worker == runtime_b.worker) ); + let runtime_a_key = store + .worker_resource_key("local-dev", &runtime_a.worker) + .unwrap() + .unwrap(); + let runtime_b_key = store + .worker_resource_key("local-dev", &runtime_b.worker) + .unwrap() + .unwrap(); + assert_ne!(runtime_a_key, runtime_b_key); + assert_eq!( + store + .resolve_worker_resource_reference("local-dev", runtime_a_key.as_str()) + .unwrap(), + Some(runtime_a.worker.clone()) + ); + assert_eq!( + store + .resolve_worker_resource_reference("local-dev", runtime_b_key.as_str()) + .unwrap(), + Some(runtime_b.worker.clone()) + ); + assert!( + store + .resolve_worker_resource_reference("local-dev", "shared-local-id") + .unwrap() + .is_none(), + "a bare local Worker ID must not resolve across multiple Runtimes" + ); + let projected_keys = snapshot + .iter() + .map(|row| row.resource_key.as_deref().unwrap()) + .collect::>(); + assert_eq!(projected_keys.len(), 2); store .delete_worker_registry("local-dev", &runtime_a.worker) @@ -20303,7 +20507,7 @@ INSERT INTO worker_registry ( store.upsert_worker_registry(&catalog).unwrap(); assert_eq!( store - .resource_key("local-dev", WorkspaceResourceKind::Worker, "known") + .worker_resource_key("local-dev", &catalog.worker) .unwrap() .as_deref(), Some("W-1") From 539163acf39220573d35e48066438817c5f45acd Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 13:25:40 +0900 Subject: [PATCH 15/31] feat: generate bounded subject memory surfaces --- crates/server-api/src/lib.rs | 87 ++ crates/worker/src/controller.rs | 10 + crates/worker/src/feature/builtin.rs | 2 + crates/worker/src/feature/builtin/memory.rs | 81 ++ .../builtin/memory_surface_lifecycle.rs | 288 ++++++ .../feature/builtin/memory_surface_output.rs | 242 +++++ crates/worker/src/prompt/catalog.rs | 26 + crates/workspace-server/src/server.rs | 269 +++++- crates/workspace-server/src/subjektiv.rs | 891 +++++++++++++++++- docs/design/subjektiv-surface.md | 46 + resources/prompts/catalog.dcdl | 2 + .../subjektiv_memory_consolidation_system.md | 2 +- .../subjektiv_memory_surface_system.md | 32 + 13 files changed, 1914 insertions(+), 64 deletions(-) create mode 100644 crates/worker/src/feature/builtin/memory_surface_lifecycle.rs create mode 100644 crates/worker/src/feature/builtin/memory_surface_output.rs create mode 100644 docs/design/subjektiv-surface.md create mode 100644 resources/prompts/internal/subjektiv_memory_surface_system.md diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 089d7c65c..3ff4bca70 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1079,6 +1079,7 @@ pub trait ServerApi { )] async fn memory_backend( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[body] request: MemoryBackendRequest, ) -> Result; @@ -4825,6 +4826,86 @@ pub struct SubjektivMemoryCandidateDecisionResponse { pub surface_dirty: bool, } +/// One bounded current Memory supplied to the surface editor. The Backend +/// selects these records deterministically; callers cannot name arbitrary +/// Memory revisions or broaden the generation input. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfaceMaterial { + pub memory_id: String, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub kind: memory::extract::CandidateKind, + pub body_md: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfacePrepareRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfacePrepareResponse { + pub generation_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub store_revision: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub active_memory_count: usize, + pub materials: Vec, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))] + pub body_token_budget: usize, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))] + pub input_token_budget: usize, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))] + pub per_kind_limit: usize, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))] + pub total_material_limit: usize, +} + +/// One model-edited Markdown point and its exact confirmed-Memory support. +/// Existence and scope are mechanically validated; semantic correctness is +/// deliberately not claimed by that validation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfacePoint { + pub body_md: String, + pub memory_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfacePublishRequest { + pub generation_id: String, + pub points: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfaceFailureRequest { + pub generation_id: String, + pub reason_code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfacePublishResponse { + pub snapshot_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub built_from_store_revision: u64, + pub empty: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSurfaceFailureResponse { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub store_revision: u64, + pub status: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { @@ -4837,6 +4918,9 @@ pub enum SubjektivMemoryBackendOperation { ListCandidates(SubjektivMemoryCandidateListRequest), ReadCandidate(SubjektivMemoryCandidateReadRequest), DecideCandidate(SubjektivMemoryCandidateDecisionRequest), + PrepareSurface(SubjektivSurfacePrepareRequest), + PublishSurface(SubjektivSurfacePublishRequest), + FailSurface(SubjektivSurfaceFailureRequest), } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -5005,6 +5089,9 @@ pub enum SubjektivMemoryBackendResponse { Candidates(SubjektivMemoryCandidateListResponse), Candidate(SubjektivMemoryCandidate), CandidateDecided(SubjektivMemoryCandidateDecisionResponse), + SurfacePrepared(SubjektivSurfacePrepareResponse), + SurfacePublished(SubjektivSurfacePublishResponse), + SurfaceFailed(SubjektivSurfaceFailureResponse), } pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 0fee205bb..0597a926d 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1313,6 +1313,16 @@ where { feature_registry.add_module(memory_lifecycle); } + if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest( + worker.manifest_lifecycle_features_enabled(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + )? { + feature_registry.add_module(surface_lifecycle); + } if let Some(subjektiv_lifecycle) = crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( worker.manifest_lifecycle_features_enabled(), diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 2469afd82..3adbc5987 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -11,6 +11,8 @@ pub mod manage_worker; pub mod memory; pub(crate) mod memory_lifecycle; pub mod memory_staging_output; +pub(crate) mod memory_surface_lifecycle; +pub(crate) mod memory_surface_output; pub mod merge_request; pub mod objective; pub mod orchestration; diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index cafe6545d..3aa2f50db 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -93,6 +93,87 @@ impl dyn WorkspaceClient + '_ { ) -> Result { execute_memory_consolidation(self, operation, true).await } + + pub async fn prepare_subjektiv_memory_surface( + &self, + ) -> Result { + match execute_subjektiv_memory_operation( + self, + server_api::SubjektivMemoryBackendOperation::PrepareSurface( + server_api::SubjektivSurfacePrepareRequest {}, + ), + ) + .await? + { + server_api::SubjektivMemoryBackendResponse::SurfacePrepared(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::Backend(format!( + "unexpected surface preparation response: {other:?}" + ))), + } + } + + pub async fn publish_subjektiv_memory_surface( + &self, + input: server_api::SubjektivSurfacePublishRequest, + ) -> Result { + match execute_subjektiv_memory_operation( + self, + server_api::SubjektivMemoryBackendOperation::PublishSurface(input), + ) + .await? + { + server_api::SubjektivMemoryBackendResponse::SurfacePublished(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::Backend(format!( + "unexpected surface publication response: {other:?}" + ))), + } + } + + pub async fn fail_subjektiv_memory_surface( + &self, + input: server_api::SubjektivSurfaceFailureRequest, + ) -> Result { + match execute_subjektiv_memory_operation( + self, + server_api::SubjektivMemoryBackendOperation::FailSurface(input), + ) + .await? + { + server_api::SubjektivMemoryBackendResponse::SurfaceFailed(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::Backend(format!( + "unexpected surface failure response: {other:?}" + ))), + } + } +} + +async fn execute_subjektiv_memory_operation( + client: &dyn WorkspaceClient, + operation: server_api::SubjektivMemoryBackendOperation, +) -> Result { + let workspace_id = + client + .workspace_id() + .ok_or_else(|| WorkspaceMemoryBackendError::Unavailable { + reason: format!( + "workspace client kind `{}` has no workspace id", + client.kind() + ), + })?; + let response = client.execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("/api/w/{workspace_id}/subjektiv/memory"), + serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?, + ))?; + let status = reqwest::StatusCode::from_u16(response.status) + .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); + if !response.is_success() { + return Err(WorkspaceMemoryBackendError::Http { + status, + body: response.body, + }); + } + Ok(serde_json::from_str(&response.body)?) } async fn execute_memory_backend( diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs new file mode 100644 index 000000000..db7b3f9be --- /dev/null +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -0,0 +1,288 @@ +use std::sync::Arc; +use std::time::Duration; + +use agen::llm_client::LlmClient; +use arc_swap::ArcSwap; +use async_trait::async_trait; + +use crate::PromptCatalog; +use crate::Scope; +use crate::feature::background::{ + BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger, + FeatureBackgroundTask, +}; +use crate::feature::builtin::memory::WorkspaceMemoryBackendError; +use crate::feature::builtin::memory_surface_output::{ + MemorySurfaceOutputFeature, MemorySurfaceOutputState, +}; +use crate::feature::{ + BackgroundTaskDeclaration, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, + FeatureModule, FeatureRegistryBuilder, +}; +use crate::hook::{HookError, HookErrorCategory}; +use crate::internal_worker::{ + InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, + run_internal_worker_with_cancel_sender, +}; +use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient}; +use manifest::WorkerManifest; + +const TASK_NAME: &str = "subjektiv-memory-surface"; +const TASK_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_GENERATION_ATTEMPTS: usize = 2; +const EDITOR_MAX_TURNS: u32 = 3; + +#[derive(Clone)] +pub(crate) struct SubjektivSurfaceLifecycleFeature { + task: SubjektivSurfaceLifecycleTask, +} + +#[derive(Clone)] +struct SubjektivSurfaceLifecycleTask { + workspace_client: Arc, + manifest: WorkerManifest, + client: Box, + prompts: Arc>, + workspace_context: WorkerWorkspaceContext, +} + +impl SubjektivSurfaceLifecycleFeature { + pub(crate) fn from_manifest( + lifecycle_enabled: bool, + workspace_client: Arc, + manifest: WorkerManifest, + client: Box, + prompts: Arc>, + workspace_context: WorkerWorkspaceContext, + ) -> std::io::Result> { + let dedicated = manifest.profile.as_ref().is_some_and(|snapshot| { + matches!( + &snapshot.source, + manifest::ProfileSource::Registry { + source: manifest::ProfileRegistrySource::Builtin, + name, + .. + } if name == "subjektiv-memory-consolidation" + ) + }); + if !lifecycle_enabled || !dedicated { + return Ok(None); + } + if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv surface generation requires Backend Workspace API authority", + )); + } + Ok(Some(Self { + task: SubjektivSurfaceLifecycleTask { + workspace_client, + manifest, + client, + prompts, + workspace_context, + }, + })) + } +} + +impl FeatureModule for SubjektivSurfaceLifecycleFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("subjektiv-memory-surface-lifecycle", "subjektiv Memory Surface Lifecycle") + .with_description("Rebuilds one bounded subject Memory surface after each committed consolidation run.") + .with_background_task(BackgroundTaskDeclaration::worker_managed( + TASK_NAME, + "Generate and publish a current subject Memory surface from bounded confirmed revisions.", + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + let declaration = BackgroundTaskDeclaration::worker_managed( + TASK_NAME, + "Generate and publish a current subject Memory surface from bounded confirmed revisions.", + ); + let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT); + spec.trigger = BackgroundTaskTrigger::RunCommitted; + context.background_tasks().register(spec, self.task.clone()) + } +} + +#[async_trait] +impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask { + async fn run( + &self, + context: BackgroundTaskContext, + cancellation: BackgroundTaskCancellation, + ) -> Result<(), HookError> { + for attempt in 0..MAX_GENERATION_ATTEMPTS { + context.generation_fence.ensure_current()?; + if cancellation.is_cancelled() { + return Ok(()); + } + let generation = self + .workspace_client + .prepare_subjektiv_memory_surface() + .await + .map_err(surface_hook_error)?; + if generation.materials.is_empty() && generation.active_memory_count > 0 { + self.record_failure(&generation.generation_id, "input_budget_exhausted") + .await; + tracing::warn!( + active_memory_count = generation.active_memory_count, + "subject Memory surface materials could not fit the bounded input" + ); + return Ok(()); + } + let points = if generation.materials.is_empty() { + Vec::new() + } else { + match self.edit_surface(&generation, cancellation.clone()).await { + Ok(points) => points, + Err(error) => { + self.record_failure(&generation.generation_id, "editor_failed") + .await; + tracing::warn!(%error, "subject Memory surface editor failed"); + return Ok(()); + } + } + }; + context.generation_fence.ensure_current()?; + if cancellation.is_cancelled() { + return Ok(()); + } + let publish = self + .workspace_client + .publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest { + generation_id: generation.generation_id.clone(), + points, + }) + .await; + match publish { + Ok(output) => { + tracing::debug!( + snapshot_id = output.snapshot_id, + store_revision = output.built_from_store_revision, + empty = output.empty, + "published subject Memory surface" + ); + return Ok(()); + } + Err(WorkspaceMemoryBackendError::Http { status, .. }) + if status == reqwest::StatusCode::CONFLICT + && attempt + 1 < MAX_GENERATION_ATTEMPTS => + { + // Confirmed Memory moved while editing. Discard the stale + // output and rebuild once from a fresh bounded generation. + continue; + } + Err(error) => { + self.record_failure(&generation.generation_id, "publish_failed") + .await; + tracing::warn!(%error, "subject Memory surface publication failed"); + return Ok(()); + } + } + } + Ok(()) + } +} + +impl SubjektivSurfaceLifecycleTask { + async fn edit_surface( + &self, + generation: &server_api::SubjektivSurfacePrepareResponse, + cancellation: BackgroundTaskCancellation, + ) -> Result, HookError> { + let language = self + .manifest + .feature + .memory + .workspace_settings() + .map(|settings| settings.language) + .ok_or_else(|| { + HookError::new( + HookErrorCategory::Internal, + "surface editor requires bound Workspace Memory settings", + ) + })?; + let system_prompt = self + .prompts + .load_full() + .subjektiv_memory_surface_system(&language) + .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; + let input = serde_json::to_string_pretty(&serde_json::json!({ + "question": "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。", + "body_token_budget": generation.body_token_budget, + "input_token_budget": generation.input_token_budget, + "materials": generation.materials, + })) + .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; + let output_state = MemorySurfaceOutputState::new( + generation.materials.clone(), + generation.body_token_budget, + ); + let features = FeatureRegistryBuilder::new() + .with_module(MemorySurfaceOutputFeature::new(output_state.clone())); + let cancellation_observer = cancellation.clone(); + let cancel_observer = move |sender: tokio::sync::mpsc::Sender<()>| { + tokio::spawn(async move { + cancellation_observer.cancelled().await; + let _ = sender.send(()).await; + }); + }; + let result = run_internal_worker_with_cancel_sender( + InternalWorkerSpec { + identity: InternalWorkerIdentity { + kind: "subjektiv-memory-surface", + run_id: uuid::Uuid::now_v7(), + }, + manifest: self.manifest.clone(), + client: self.client.clone_boxed(), + system_prompt, + input, + cache_key: None, + max_turns: Some(EDITOR_MAX_TURNS), + engine_configurator: None, + features, + required_tools: &["SubmitMemorySurface"], + authority: InternalWorkerAuthority { + workspace: self.workspace_context.clone(), + filesystem: WorkerFilesystemAuthority::None, + scope: Scope::empty(), + workdir_session: None, + }, + }, + cancel_observer, + ) + .await; + if let Err(error) = result { + return Err(HookError::new( + HookErrorCategory::Internal, + format!("surface editor Internal Worker failed: {}", error.source), + )); + } + output_state.submitted().ok_or_else(|| { + HookError::new( + HookErrorCategory::Internal, + "surface editor finished without valid structured output", + ) + }) + } + + async fn record_failure(&self, generation_id: &str, reason_code: &str) { + if let Err(error) = self + .workspace_client + .fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest { + generation_id: generation_id.to_string(), + reason_code: reason_code.to_string(), + }) + .await + { + tracing::debug!(%error, "could not record subject Memory surface failure"); + } + } +} + +fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError { + HookError::new(HookErrorCategory::Internal, error.to_string()) +} diff --git a/crates/worker/src/feature/builtin/memory_surface_output.rs b/crates/worker/src/feature/builtin/memory_surface_output.rs new file mode 100644 index 000000000..4b3233364 --- /dev/null +++ b/crates/worker/src/feature/builtin/memory_surface_output.rs @@ -0,0 +1,242 @@ +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, + ToolDeclaration, +}; + +const SUBMIT_TOOL: &str = "SubmitMemorySurface"; +const SUBMIT_DESCRIPTION: &str = "Submit a bounded Markdown Memory surface whose every point cites exact refs from the supplied confirmed-Memory materials. Reference validation proves existence and scope, not semantic correctness."; + +#[derive(Clone)] +pub(crate) struct MemorySurfaceOutputState { + materials: Arc>, + body_token_budget: usize, + submitted: Arc>>>, +} + +impl MemorySurfaceOutputState { + pub(crate) fn new( + materials: Vec, + body_token_budget: usize, + ) -> Self { + Self { + materials: Arc::new(materials), + body_token_budget, + submitted: Arc::new(Mutex::new(None)), + } + } + + pub(crate) fn submitted(&self) -> Option> { + self.submitted + .lock() + .expect("Memory surface output state poisoned") + .clone() + } + + fn validate(&self, points: &[server_api::SubjektivSurfacePoint]) -> Result<(), ToolError> { + if self.materials.is_empty() { + return if points.is_empty() { + Ok(()) + } else { + Err(ToolError::InvalidArgument( + "empty materials require an empty points array".into(), + )) + }; + } + if points.is_empty() { + return Err(ToolError::InvalidArgument( + "non-empty materials require at least one grounded point".into(), + )); + } + if points.len() > 24 { + return Err(ToolError::InvalidArgument(format!( + "points count {} exceeds the configured limit 24", + points.len() + ))); + } + let allowed = self + .materials + .iter() + .map(|material| (material.memory_id.as_str(), material.revision)) + .collect::>(); + let mut bodies = Vec::with_capacity(points.len()); + for point in points { + let body = point.body_md.trim(); + if body.is_empty() { + return Err(ToolError::InvalidArgument( + "surface point body_md must not be empty".into(), + )); + } + if point.memory_refs.is_empty() { + return Err(ToolError::InvalidArgument( + "every surface point requires at least one memory_ref".into(), + )); + } + let mut point_refs = HashSet::new(); + for reference in &point.memory_refs { + if !point_refs.insert((reference.memory_id.as_str(), reference.revision)) { + return Err(ToolError::InvalidArgument(format!( + "duplicate surface point ref {}@{}", + reference.memory_id, reference.revision + ))); + } + if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { + return Err(ToolError::InvalidArgument(format!( + "surface ref {}@{} is not in the generation materials", + reference.memory_id, reference.revision + ))); + } + } + bodies.push(body); + } + let bytes = bodies + .iter() + .map(|body| body.len()) + .sum::() + .saturating_add(points.len().saturating_sub(1) * 2); + let tokens = bytes.saturating_add(3) / 4; + if tokens > self.body_token_budget { + return Err(ToolError::InvalidArgument(format!( + "surface body token estimate {tokens} exceeds budget {}", + self.body_token_budget + ))); + } + Ok(()) + } +} + +#[derive(Clone)] +pub(crate) struct MemorySurfaceOutputFeature { + state: MemorySurfaceOutputState, +} + +impl MemorySurfaceOutputFeature { + pub(crate) fn new(state: MemorySurfaceOutputState) -> Self { + Self { state } + } +} + +impl FeatureModule for MemorySurfaceOutputFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("memory-surface-output", "Memory Surface Output") + .with_description( + "Restricted structured output for one clean-context Memory surface editor.", + ) + .with_tool(ToolDeclaration::new(SUBMIT_TOOL, SUBMIT_DESCRIPTION)) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context.tools().register(ToolContribution::new( + SUBMIT_TOOL, + submit_definition(self.state.clone()), + )) + } +} + +fn submit_definition(state: MemorySurfaceOutputState) -> ToolDefinition { + Arc::new(move || { + let schema = serde_json::to_value(schemars::schema_for!(SubmitMemorySurfaceParams)) + .unwrap_or_else(|_| serde_json::json!({})); + let meta = ToolMeta::new(SUBMIT_TOOL) + .description(SUBMIT_DESCRIPTION) + .input_schema(schema); + let tool: Arc = Arc::new(SubmitMemorySurfaceTool { + state: state.clone(), + }); + (meta, tool) + }) +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SubmitMemorySurfaceParams { + points: Vec, +} + +struct SubmitMemorySurfaceTool { + state: MemorySurfaceOutputState, +} + +#[async_trait] +impl Tool for SubmitMemorySurfaceTool { + async fn execute( + &self, + input_json: &str, + _context: agen::tool::ToolExecutionContext, + ) -> Result { + let params: SubmitMemorySurfaceParams = + serde_json::from_str(input_json).map_err(|error| { + ToolError::InvalidArgument(format!("invalid SubmitMemorySurface input: {error}")) + })?; + self.state.validate(¶ms.points)?; + let mut submitted = self + .state + .submitted + .lock() + .expect("Memory surface output state poisoned"); + if submitted.is_some() { + return Err(ToolError::InvalidArgument( + "SubmitMemorySurface already accepted output for this generation".into(), + )); + } + let count = params.points.len(); + *submitted = Some(params.points); + Ok(ToolOutput { + summary: format!("Accepted {count} grounded Memory surface point(s)."), + content: None, + attachments: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use memory::extract::CandidateKind; + + fn material(id: &str) -> server_api::SubjektivSurfaceMaterial { + server_api::SubjektivSurfaceMaterial { + memory_id: id.into(), + revision: 1, + kind: CandidateKind::Constraint, + body_md: "Keep the boundary.".into(), + why_useful: "Avoid regression.".into(), + staleness: Some("Until the boundary changes.".into()), + } + } + + fn point(id: &str) -> server_api::SubjektivSurfacePoint { + server_api::SubjektivSurfacePoint { + body_md: "- Keep the boundary unless it changes.".into(), + memory_refs: vec![server_api::SubjektivMemoryRevisionRef { + memory_id: id.into(), + revision: 1, + }], + } + } + + #[test] + fn rejects_missing_or_material_external_refs_and_budget_overflow() { + let state = MemorySurfaceOutputState::new(vec![material("allowed")], 32); + assert!(state.validate(&[]).is_err()); + assert!(state.validate(&[point("other")]).is_err()); + let mut oversized = point("allowed"); + oversized.body_md = "x".repeat(132); + assert!(state.validate(&[oversized]).is_err()); + assert!(state.validate(&[point("allowed")]).is_ok()); + } + + #[test] + fn empty_materials_accept_only_normal_empty_output() { + let state = MemorySurfaceOutputState::new(Vec::new(), 32); + assert!(state.validate(&[]).is_ok()); + assert!(state.validate(&[point("invented")]).is_err()); + } +} diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 13b32e874..685476d3a 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -95,6 +95,7 @@ pub enum WorkerPrompt { MemoryExtractSystem, MemoryConsolidationSystem, SubjektivMemoryConsolidationSystem, + SubjektivMemorySurfaceSystem, FlowVerifierSystem, BackendJobSystem, NotifyWrapper, @@ -116,6 +117,7 @@ impl WorkerPrompt { Self::SubjektivMemoryConsolidationSystem => { "internal.subjektiv_memory_consolidation_system" } + Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system", Self::FlowVerifierSystem => "internal.flow_verifier_system", Self::BackendJobSystem => "internal.backend_job_system", Self::NotifyWrapper => "internal.notify_wrapper", @@ -136,6 +138,7 @@ impl WorkerPrompt { WorkerPrompt::MemoryExtractSystem, WorkerPrompt::MemoryConsolidationSystem, WorkerPrompt::SubjektivMemoryConsolidationSystem, + WorkerPrompt::SubjektivMemorySurfaceSystem, WorkerPrompt::FlowVerifierSystem, WorkerPrompt::BackendJobSystem, WorkerPrompt::NotifyWrapper, @@ -463,6 +466,12 @@ impl PromptCatalog { single("language", language), ) } + pub fn subjektiv_memory_surface_system(&self, language: &str) -> Result { + self.render( + WorkerPrompt::SubjektivMemorySurfaceSystem, + single("language", language), + ) + } pub fn flow_verifier_system(&self) -> Result { self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED) } @@ -927,6 +936,23 @@ mod tests { .unwrap() .contains("LANGUAGE_MARKER") ); + let surface = catalog + .subjektiv_memory_surface_system("SURFACE_LANGUAGE") + .unwrap(); + assert!(surface.contains("SURFACE_LANGUAGE")); + for required in [ + "conditions", + "negation", + "contradictions", + "staleness", + "SubmitMemorySurface", + "not semantic verification", + ] { + assert!( + surface.contains(required), + "missing surface rule {required}" + ); + } assert!( catalog .notify_wrapper("NOTIFICATION_MARKER") diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 83e337b35..6fea2c5e5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -20,8 +20,8 @@ use config_source::ConfigTreeSnapshot; use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ - MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, - MemoryConsolidationOutput, + MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, + MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryToolOutput, }; use protocol::Segment; use protocol::stream::{decode_method, encode_event}; @@ -6998,6 +6998,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn memory_backend( &self, + context: server_api::ServerRequestContext, workspace_id: String, request: server_api::MemoryBackendRequest, ) -> std::result::Result @@ -7005,6 +7006,7 @@ impl server_api::ServerApi for ServerApiContractService { scoped_memory_backend_operation( State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), + context, Json(request.0), ) .await @@ -16532,11 +16534,17 @@ async fn scoped_list_memory_staging( async fn scoped_memory_backend_operation( State(api): State, AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, Json(operation): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let response = match execute_memory_backend_operation_with_authority(&api.authority, operation) - { + let result = match operation { + MemoryBackendOperation::ResidentSummary(_) => { + execute_scoped_resident_summary(&api, &context) + } + operation => execute_memory_backend_operation_with_authority(&api.authority, operation), + }; + let response = match result { Ok(result) => MemoryBackendHttpResponse::Ok { result }, Err(error) => MemoryBackendHttpResponse::Error { message: sanitize_backend_error(&error.to_string()), @@ -16545,6 +16553,73 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } +fn execute_scoped_resident_summary( + api: &WorkspaceApi, + context: &server_api::ServerRequestContext, +) -> crate::Result { + let Some(source) = context.runtime_source.as_ref() else { + return execute_memory_backend_operation_with_authority( + &api.authority, + MemoryBackendOperation::ResidentSummary(Default::default()), + ); + }; + let Some(worker_id) = source.worker_id.as_deref() else { + return execute_memory_backend_operation_with_authority( + &api.authority, + MemoryBackendOperation::ResidentSummary(Default::default()), + ); + }; + let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); + let Some(lease) = api + .store + .require_current_worker_singleton_owner(&api.config.workspace_id, &worker)? + else { + return execute_memory_backend_operation_with_authority( + &api.authority, + MemoryBackendOperation::ResidentSummary(Default::default()), + ); + }; + let Some(subject_id) = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else { + return execute_memory_backend_operation_with_authority( + &api.authority, + MemoryBackendOperation::ResidentSummary(Default::default()), + ); + }; + let resident = + crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) + .map_err(|error| Error::Store(error.to_string()))? + .resident_surface(subject_id) + .map_err(|error| Error::Store(error.to_string()))?; + let (summary, content) = match resident.availability { + crate::subjektiv::SurfaceAvailability::Ready => { + let snapshot = resident.snapshot.expect("ready surface has a snapshot"); + if snapshot.body_md.is_empty() { + ("subject resident Memory surface is normally empty", None) + } else { + ( + "subject resident Memory surface collected", + Some(snapshot.body_md), + ) + } + } + crate::subjektiv::SurfaceAvailability::Ungenerated => { + ("subject resident Memory surface is not generated", None) + } + crate::subjektiv::SurfaceAvailability::Stale => ( + "subject resident Memory surface is stale and was omitted", + None, + ), + crate::subjektiv::SurfaceAvailability::Failed => ( + "subject resident Memory surface generation failed and was omitted", + None, + ), + }; + Ok(MemoryBackendOperationResult::ToolOutput(MemoryToolOutput { + summary: summary.into(), + content, + })) +} + const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; @@ -17069,10 +17144,97 @@ async fn scoped_subjektiv_memory_backend( subjektiv_candidate_decision_response(receipt), ) } + server_api::SubjektivMemoryBackendOperation::PrepareSurface(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "surface generation preparation", + )?; + let generation = store + .prepare_surface_generation(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::SurfacePrepared( + subjektiv_surface_prepare_response(generation), + ) + } + server_api::SubjektivMemoryBackendOperation::PublishSurface(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "surface publication", + )?; + let points = input + .points + .into_iter() + .map(|point| crate::subjektiv::SurfacePoint { + body_md: point.body_md, + memory_refs: point + .memory_refs + .into_iter() + .map(|reference| crate::subjektiv::MemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + }) + .collect(); + let snapshot = store + .publish_surface_generation(&subject_id, &input.generation_id, points) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::SurfacePublished( + server_api::SubjektivSurfacePublishResponse { + snapshot_id: snapshot.id, + built_from_store_revision: snapshot.built_from_store_revision, + empty: snapshot.body_md.is_empty(), + }, + ) + } + server_api::SubjektivMemoryBackendOperation::FailSurface(input) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Consolidation, + "surface generation failure", + )?; + let revision = store + .fail_surface_generation(&subject_id, &input.generation_id, &input.reason_code) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::SurfaceFailed( + server_api::SubjektivSurfaceFailureResponse { + store_revision: revision, + status: "failed".into(), + }, + ) + } }; Ok(Json(response)) } +fn subjektiv_surface_prepare_response( + generation: crate::subjektiv::SurfaceGeneration, +) -> server_api::SubjektivSurfacePrepareResponse { + server_api::SubjektivSurfacePrepareResponse { + generation_id: generation.id, + store_revision: generation.store_revision, + active_memory_count: generation.active_memory_count, + materials: generation + .materials + .into_iter() + .map(|material| server_api::SubjektivSurfaceMaterial { + memory_id: material.memory_id, + revision: material.revision, + kind: material.kind, + body_md: material.body_md, + why_useful: material.why_useful, + staleness: material.staleness, + }) + .collect(), + body_token_budget: crate::subjektiv::SURFACE_BODY_TOKEN_BUDGET, + input_token_budget: crate::subjektiv::SURFACE_INPUT_TOKEN_BUDGET, + per_kind_limit: crate::subjektiv::SURFACE_PER_KIND_LIMIT, + total_material_limit: crate::subjektiv::SURFACE_TOTAL_MATERIAL_LIMIT, + } +} + fn subjektiv_candidate_summary( candidate: crate::subjektiv::SubjectStagingRecord, ) -> server_api::SubjektivMemoryCandidateSummary { @@ -18215,7 +18377,8 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } - crate::subjektiv::SubjektivError::RevisionConflict { .. } => { + crate::subjektiv::SubjektivError::RevisionConflict { .. } + | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { Error::RepositoryConflict(format!("revision_conflict: {error}")) } crate::subjektiv::SubjektivError::CandidateResolved(_) @@ -18253,28 +18416,91 @@ async fn scoped_subjektiv_memory_consolidation( )?)) } +const SUBJECT_CONSOLIDATION_THRESHOLD_FILES: usize = 5; +const SUBJECT_CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; + +fn subject_consolidation_required( + candidate_count: usize, + total_bytes: u64, + force: bool, + surface_availability: crate::subjektiv::SurfaceAvailability, +) -> bool { + let surface_needs_generation = + surface_availability != crate::subjektiv::SurfaceAvailability::Ready; + if candidate_count == 0 { + return surface_needs_generation; + } + force + || surface_needs_generation + || candidate_count >= SUBJECT_CONSOLIDATION_THRESHOLD_FILES + || total_bytes >= SUBJECT_CONSOLIDATION_THRESHOLD_BYTES +} + +#[cfg(test)] +mod subject_surface_consolidation_policy_tests { + use super::*; + use crate::subjektiv::SurfaceAvailability; + + #[test] + fn failed_stale_and_ungenerated_surfaces_bypass_candidate_thresholds() { + for availability in [ + SurfaceAvailability::Failed, + SurfaceAvailability::Stale, + SurfaceAvailability::Ungenerated, + ] { + assert!(subject_consolidation_required(0, 0, false, availability)); + assert!(subject_consolidation_required(1, 1, false, availability)); + } + assert!(!subject_consolidation_required( + 0, + 0, + true, + SurfaceAvailability::Ready + )); + assert!(!subject_consolidation_required( + 1, + 1, + false, + SurfaceAvailability::Ready + )); + assert!(subject_consolidation_required( + SUBJECT_CONSOLIDATION_THRESHOLD_FILES, + 1, + false, + SurfaceAvailability::Ready + )); + } +} + fn start_subjektiv_staging_consolidation( api: WorkspaceApi, subject_id: &str, operation: MemoryConsolidateStagingOperation, ) -> ApiResult { - let (candidate_count, total_bytes) = open_subjektiv_store(&api)? + let store = open_subjektiv_store(&api)?; + let (candidate_count, total_bytes) = store .pending_staging_backlog(subject_id) .map_err(subjektiv_store_error)?; - if candidate_count == 0 { - return Ok(MemoryConsolidationOutput { - status: "skipped_empty".into(), - summary: "No subject Memory staging candidates are pending.".into(), - candidate_count, - total_bytes, - }); - } - const CONSOLIDATION_THRESHOLD_FILES: usize = 5; - const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; - if !operation.force - && candidate_count < CONSOLIDATION_THRESHOLD_FILES - && total_bytes < CONSOLIDATION_THRESHOLD_BYTES - { + let surface_availability = store + .resident_surface(subject_id) + .map_err(subjektiv_store_error)? + .availability; + if !subject_consolidation_required( + candidate_count, + total_bytes, + operation.force, + surface_availability, + ) { + if candidate_count == 0 { + return Ok(MemoryConsolidationOutput { + status: "skipped_empty".into(), + summary: + "No subject Memory staging candidates are pending and the surface is current." + .into(), + candidate_count, + total_bytes, + }); + } return Ok(MemoryConsolidationOutput { status: "skipped_below_threshold".into(), summary: format!( @@ -18288,7 +18514,8 @@ fn start_subjektiv_staging_consolidation( let runtime_id = select_memory_consolidation_runtime(&api)?; let singleton_key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}"); let input_content = format!( - "Process the delegated subject's {candidate_count} pending Memory candidate(s) ({total_bytes} bytes) with MemoryStagingList, MemoryStagingRead, subject Memory reads, and MemoryApplyCandidate." + "Process the delegated subject's {candidate_count} pending Memory candidate(s) ({total_bytes} bytes) with MemoryStagingList, MemoryStagingRead, subject Memory reads, and MemoryApplyCandidate. After this committed consolidation turn, the Host will rebuild the {:?} resident surface from confirmed Memory in a separate clean context.", + surface_availability ); if let Some(output) = try_reuse_subject_consolidation_worker( &api, diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index a5b92a4f9..3b5a8708a 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -23,6 +23,15 @@ use crate::feature_storage::{ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; pub const MAX_STAGING_ANCHORS: usize = 10; +/// Deterministic surface-generation policy. Input estimates use the repository's +/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material +/// payload is capped below the whole-input budget to reserve room for the fixed +/// prompt and JSON framing. +pub const SURFACE_BODY_TOKEN_BUDGET: usize = 1_024; +pub const SURFACE_INPUT_TOKEN_BUDGET: usize = 12_000; +pub const SURFACE_MATERIAL_TOKEN_BUDGET: usize = 10_000; +pub const SURFACE_PER_KIND_LIMIT: usize = 8; +pub const SURFACE_TOTAL_MATERIAL_LIMIT: usize = 24; pub type Result = std::result::Result; @@ -62,6 +71,8 @@ pub enum SubjektivError { expected: u64, actual: u64, }, + #[error("surface generation conflict: {0}")] + SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] InvalidStateTransition { memory_id: String, @@ -401,7 +412,7 @@ pub struct StagingResolution { pub resolved_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SurfaceSnapshot { pub schema_version: u32, @@ -414,6 +425,49 @@ pub struct SurfaceSnapshot { pub created_at: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceMaterial { + pub memory_id: String, + pub revision: u64, + pub kind: CandidateKind, + pub body_md: String, + pub why_useful: String, + pub staleness: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceGeneration { + pub id: String, + pub subject_id: String, + pub store_revision: u64, + pub active_memory_count: usize, + pub materials: Vec, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfacePoint { + pub body_md: String, + pub memory_refs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceAvailability { + Ungenerated, + Stale, + Failed, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResidentSurface { + pub availability: SurfaceAvailability, + pub snapshot: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryRevisionTarget { @@ -841,6 +895,75 @@ END; Ok(()) } +fn add_surface_generation_state( + transaction: &Transaction<'_>, +) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE surface_generation_state ( + subject_id TEXT PRIMARY KEY, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), + snapshot_id TEXT, + reason_code TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at +) +SELECT subjects.subject_id, + subjects.store_revision, + CASE WHEN EXISTS ( + SELECT 1 FROM surface_snapshots current + WHERE current.subject_id = subjects.subject_id + AND current.built_from_store_revision = subjects.store_revision + ) THEN 'ready' ELSE 'dirty' END, + ( + SELECT current.snapshot_id FROM surface_snapshots current + WHERE current.subject_id = subjects.subject_id + AND current.built_from_store_revision = subjects.store_revision + ORDER BY current.created_at ASC, current.snapshot_id ASC + LIMIT 1 + ), + NULL, + subjects.updated_at +FROM subjects +WHERE EXISTS ( + SELECT 1 FROM surface_snapshots any_snapshot + WHERE any_snapshot.subject_id = subjects.subject_id +); + +CREATE TABLE surface_generation_runs ( + subject_id TEXT NOT NULL, + generation_id TEXT NOT NULL, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), + materials_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, generation_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX surface_generation_runs_by_revision +ON surface_generation_runs(subject_id, store_revision, created_at, generation_id); + +CREATE TRIGGER surface_generation_runs_no_update +BEFORE UPDATE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); +END; +CREATE TRIGGER surface_generation_runs_no_delete +BEFORE DELETE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); +END; +"#, + )?; + Ok(()) +} + static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -853,6 +976,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add idempotent atomic candidate decision receipts", add_candidate_decision_receipts, ), + FeatureMigration::new( + 4, + "add bounded surface generation state and runs", + add_surface_generation_state, + ), ]; pub const REGISTRATION: FeatureRegistration = @@ -1519,6 +1647,225 @@ impl SubjektivStore { }) } + /// Captures one deterministic, bounded generation input from only current + /// active Memory revisions. Kind order is fixed and selection is round-robin + /// across kinds; within each kind, updated_at desc then memory id asc. + pub fn prepare_surface_generation(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let (active_memory_count, materials) = + select_surface_materials(transaction, subject_id)?; + let generation = SurfaceGeneration { + id: issued_id("surface-generation"), + subject_id: subject_id.to_string(), + store_revision: subject.store_revision, + active_memory_count, + materials, + created_at: now(), + }; + transaction.execute( + "INSERT INTO surface_generation_runs ( + subject_id, generation_id, store_revision, active_memory_count, + materials_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + subject_id, + generation.id, + to_i64(generation.store_revision)?, + to_i64(generation.active_memory_count as u64)?, + serde_json::to_string(&generation.materials)?, + generation.created_at + ], + )?; + Ok(generation) + }) + } + + /// Validates structured editor output against the exact material snapshot and + /// atomically publishes it only while the subject store generation is current. + pub fn publish_surface_generation( + &self, + subject_id: &str, + generation_id: &str, + points: Vec, + ) -> Result { + validate_label("surface generation id", generation_id)?; + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let (store_revision, active_memory_count, materials_json) = transaction + .query_row( + "SELECT store_revision, active_memory_count, materials_json + FROM surface_generation_runs + WHERE subject_id = ?1 AND generation_id = ?2", + params![subject_id, generation_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()? + .ok_or_else(|| { + SubjektivError::SurfaceGenerationConflict(format!( + "unknown generation `{generation_id}` for subject `{subject_id}`" + )) + })?; + let store_revision = u64::try_from(store_revision).map_err(|_| { + SubjektivError::InvalidRecord("surface generation revision is negative".into()) + })?; + if subject.store_revision != store_revision { + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "generation {store_revision} is stale; current store revision is {}", + subject.store_revision + ))); + } + let materials: Vec = serde_json::from_str(&materials_json)?; + if active_memory_count > 0 && materials.is_empty() { + return Err(SubjektivError::InvalidRecord( + "active Memory exists but no record fits the surface input budget".into(), + )); + } + let (body_md, memory_refs) = validate_surface_points(&materials, &points)?; + if let Some(existing) = + surface_snapshot_for_revision(transaction, subject_id, store_revision)? + { + if existing.body_md == body_md && existing.memory_refs == memory_refs { + return Ok(existing); + } + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "store revision {store_revision} already has a different published surface" + ))); + } + insert_surface_snapshot( + transaction, + subject_id, + body_md, + memory_refs, + store_revision, + ) + }) + } + + /// Records a bounded generator failure without changing confirmed Memory. A + /// late failure cannot replace a ready surface or dirty a newer generation. + pub fn fail_surface_generation( + &self, + subject_id: &str, + generation_id: &str, + reason_code: &str, + ) -> Result { + validate_label("surface generation id", generation_id)?; + validate_label("surface failure reason", reason_code)?; + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let generation_revision = transaction + .query_row( + "SELECT store_revision FROM surface_generation_runs + WHERE subject_id = ?1 AND generation_id = ?2", + params![subject_id, generation_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| { + SubjektivError::SurfaceGenerationConflict(format!( + "unknown generation `{generation_id}` for subject `{subject_id}`" + )) + })?; + let generation_revision = u64::try_from(generation_revision).map_err(|_| { + SubjektivError::InvalidRecord("surface generation revision is negative".into()) + })?; + if generation_revision == subject.store_revision { + let ready = transaction + .query_row( + "SELECT status FROM surface_generation_state WHERE subject_id = ?1", + [subject_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .is_some_and(|status| status == "ready"); + if !ready { + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'failed', NULL, ?3, ?4) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'failed', snapshot_id = NULL, + reason_code = excluded.reason_code, + updated_at = excluded.updated_at", + params![subject_id, to_i64(generation_revision)?, reason_code, now()], + )?; + } + } + Ok(subject.store_revision) + }) + } + + /// Returns only a current ready snapshot for injection. Other states are + /// explicit so callers never report missing/stale/failed generation as empty + /// confirmed Memory. + pub fn resident_surface(&self, subject_id: &str) -> Result { + self.database.try_with_connection(|connection| { + let subject = require_subject_in_connection(connection, subject_id)?; + let state = connection + .query_row( + "SELECT store_revision, status, snapshot_id + FROM surface_generation_state WHERE subject_id = ?1", + [subject_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((revision, status, snapshot_id)) = state else { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Ungenerated, + snapshot: None, + }); + }; + let revision = u64::try_from(revision).map_err(|_| { + SubjektivError::InvalidRecord("surface state revision is negative".into()) + })?; + if revision != subject.store_revision || status == "dirty" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Stale, + snapshot: None, + }); + } + if status == "failed" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Failed, + snapshot: None, + }); + } + let snapshot_id = snapshot_id.ok_or_else(|| { + SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) + })?; + let raw = connection.query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject_id, snapshot_id], + |row| row.get::<_, String>(0), + )?; + let snapshot = parse_surface_snapshot(&raw)?; + if snapshot.built_from_store_revision != subject.store_revision { + return Err(SubjektivError::InvalidRecord( + "ready surface snapshot generation does not match subject".into(), + )); + } + Ok(ResidentSurface { + availability: SurfaceAvailability::Ready, + snapshot: Some(snapshot), + }) + }) + } + pub fn create_surface_snapshot( &self, subject_id: &str, @@ -1528,16 +1875,7 @@ impl SubjektivStore { ) -> Result { reject_duplicate_refs("surface snapshot", &memory_refs)?; reject_duplicate_memory_ids("surface snapshot", &memory_refs)?; - let snapshot = SurfaceSnapshot { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: issued_id("surface"), - subject_id: subject_id.to_string(), - body_md: body_md.into(), - memory_refs, - built_from_store_revision, - created_at: now(), - }; - let raw = serde_json::to_string(&snapshot)?; + let body_md = body_md.into(); self.database.try_transaction(|transaction| { let subject = require_active_subject(transaction, subject_id)?; if subject.store_revision != built_from_store_revision { @@ -1546,38 +1884,31 @@ impl SubjektivStore { subject.store_revision ))); } - validate_memory_refs(transaction, subject_id, &snapshot.memory_refs)?; - transaction.execute( - "INSERT INTO surface_snapshots ( - subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - subject_id, - snapshot.id, - to_i64(snapshot.built_from_store_revision)?, - raw, - snapshot.created_at - ], - )?; - for reference in &snapshot.memory_refs { - transaction.execute( - "INSERT INTO surface_snapshot_refs ( - subject_id, snapshot_id, memory_id, revision - ) VALUES (?1, ?2, ?3, ?4)", - params![ - subject_id, - snapshot.id, - reference.memory_id, - to_i64(reference.revision)? - ], - )?; + validate_memory_refs(transaction, subject_id, &memory_refs)?; + if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { + return Err(SubjektivError::InvalidRecord(format!( + "surface snapshot body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" + ))); } - transaction.execute( - "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) - VALUES (?1, ?2)", - params![subject_id, snapshot.id], - )?; - Ok(snapshot) + if let Some(existing) = surface_snapshot_for_revision( + transaction, + subject_id, + built_from_store_revision, + )? { + if existing.body_md == body_md && existing.memory_refs == memory_refs { + return Ok(existing); + } + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "store revision {built_from_store_revision} already has a different published surface" + ))); + } + insert_surface_snapshot( + transaction, + subject_id, + body_md, + memory_refs, + built_from_store_revision, + ) }) } @@ -1600,6 +1931,218 @@ impl SubjektivStore { } } +fn select_surface_materials( + transaction: &Transaction<'_>, + subject_id: &str, +) -> Result<(usize, Vec)> { + let mut statement = transaction.prepare( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND state = 'active' + ORDER BY updated_at DESC, memory_id ASC", + )?; + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut buckets: [Vec; 6] = std::array::from_fn(|_| Vec::new()); + let mut active_memory_count = 0usize; + for row in rows { + let memory = parse_memory(&row?)?; + active_memory_count = active_memory_count.saturating_add(1); + let bucket = surface_kind_index(&memory.kind); + if buckets[bucket].len() < SURFACE_PER_KIND_LIMIT { + buckets[bucket].push(SurfaceMaterial { + memory_id: memory.id, + revision: memory.revision, + kind: memory.kind, + body_md: memory.body_md, + why_useful: memory.why_useful, + staleness: memory.staleness, + }); + } + } + + let mut selected = Vec::new(); + let mut payload_bytes = 2usize; // JSON array delimiters. + for offset in 0..SURFACE_PER_KIND_LIMIT { + for bucket in &buckets { + let Some(material) = bucket.get(offset) else { + continue; + }; + if selected.len() == SURFACE_TOTAL_MATERIAL_LIMIT { + return Ok((active_memory_count, selected)); + } + let material_bytes = serde_json::to_vec(material)?.len().saturating_add(1); + if estimated_tokens_for_bytes(payload_bytes.saturating_add(material_bytes)) + > SURFACE_MATERIAL_TOKEN_BUDGET + { + continue; + } + payload_bytes = payload_bytes.saturating_add(material_bytes); + selected.push(material.clone()); + } + } + Ok((active_memory_count, selected)) +} + +fn surface_kind_index(kind: &CandidateKind) -> usize { + match kind { + CandidateKind::Preference => 0, + CandidateKind::Constraint => 1, + CandidateKind::Decision => 2, + CandidateKind::WorkingAssumption => 3, + CandidateKind::OpenQuestion => 4, + CandidateKind::Lesson => 5, + } +} + +fn validate_surface_points( + materials: &[SurfaceMaterial], + points: &[SurfacePoint], +) -> Result<(String, Vec)> { + if materials.is_empty() { + if points.is_empty() { + return Ok((String::new(), Vec::new())); + } + return Err(SubjektivError::InvalidRecord( + "an empty material set requires an empty surface".into(), + )); + } + if points.is_empty() { + return Err(SubjektivError::InvalidRecord( + "non-empty surface materials require at least one grounded point".into(), + )); + } + if points.len() > SURFACE_TOTAL_MATERIAL_LIMIT { + return Err(SubjektivError::InvalidRecord(format!( + "surface output exceeds {SURFACE_TOTAL_MATERIAL_LIMIT} points" + ))); + } + let allowed = materials + .iter() + .map(|material| (material.memory_id.as_str(), material.revision)) + .collect::>(); + let mut bodies = Vec::with_capacity(points.len()); + let mut seen = HashSet::new(); + let mut refs = Vec::new(); + for point in points { + let body = point.body_md.trim(); + validate_nonempty("surface point body", body)?; + if point.memory_refs.is_empty() { + return Err(SubjektivError::InvalidRecord( + "every surface point requires at least one Memory reference".into(), + )); + } + reject_duplicate_refs("surface point", &point.memory_refs)?; + for reference in &point.memory_refs { + if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: "surface generation materials".into(), + reference: format!("{}@{}", reference.memory_id, reference.revision), + }); + } + if seen.insert((reference.memory_id.clone(), reference.revision)) { + refs.push(reference.clone()); + } + } + bodies.push(body.to_string()); + } + let body_md = bodies.join("\n\n"); + if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { + return Err(SubjektivError::InvalidRecord(format!( + "surface body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" + ))); + } + Ok((body_md, refs)) +} + +fn surface_snapshot_for_revision( + transaction: &Transaction<'_>, + subject_id: &str, + store_revision: u64, +) -> Result> { + let raw = transaction + .query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND built_from_store_revision = ?2 + ORDER BY created_at ASC, snapshot_id ASC LIMIT 1", + params![subject_id, to_i64(store_revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_surface_snapshot(&raw)).transpose() +} + +fn insert_surface_snapshot( + transaction: &Transaction<'_>, + subject_id: &str, + body_md: String, + memory_refs: Vec, + built_from_store_revision: u64, +) -> Result { + validate_memory_refs(transaction, subject_id, &memory_refs)?; + let snapshot = SurfaceSnapshot { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: issued_id("surface"), + subject_id: subject_id.to_string(), + body_md, + memory_refs, + built_from_store_revision, + created_at: now(), + }; + let raw = serde_json::to_string(&snapshot)?; + transaction.execute( + "INSERT INTO surface_snapshots ( + subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject_id, + snapshot.id, + to_i64(snapshot.built_from_store_revision)?, + raw, + snapshot.created_at + ], + )?; + for reference in &snapshot.memory_refs { + transaction.execute( + "INSERT INTO surface_snapshot_refs ( + subject_id, snapshot_id, memory_id, revision + ) VALUES (?1, ?2, ?3, ?4)", + params![ + subject_id, + snapshot.id, + reference.memory_id, + to_i64(reference.revision)? + ], + )?; + } + transaction.execute( + "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", + params![subject_id, snapshot.id], + )?; + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'ready', ?3, NULL, ?4) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'ready', snapshot_id = excluded.snapshot_id, + reason_code = NULL, updated_at = excluded.updated_at", + params![ + subject_id, + to_i64(built_from_store_revision)?, + snapshot.id, + snapshot.created_at + ], + )?; + Ok(snapshot) +} + +fn estimated_tokens(value: &str) -> usize { + estimated_tokens_for_bytes(value.len()) +} + +fn estimated_tokens_for_bytes(bytes: usize) -> usize { + bytes.saturating_add(3) / 4 +} + fn apply_candidates_in_transaction( transaction: &Transaction<'_>, subject_id: &str, @@ -1817,6 +2360,20 @@ fn write_memory_revision( subject.updated_at ], )?; + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'dirty', NULL, NULL, ?3) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'dirty', snapshot_id = NULL, reason_code = NULL, + updated_at = excluded.updated_at", + params![ + subject_id, + to_i64(subject.store_revision)?, + subject.updated_at + ], + )?; Ok(record) } @@ -4193,6 +4750,256 @@ mod tests { ); } + #[test] + fn surface_material_selection_is_scoped_active_bounded_and_deterministic() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other = store.create_subject(role()).unwrap(); + store + .create_memory(&other.id, draft("foreign", "foreign subject")) + .unwrap(); + + let kinds = [ + CandidateKind::Preference, + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ]; + let mut first_id = None; + for kind in &kinds { + for index in 0..9 { + let mut value = draft(&format!("{kind:?}-{index}"), "selection fixture"); + value.kind = kind.clone(); + let memory = store.create_memory(&subject.id, value).unwrap(); + first_id.get_or_insert(memory.id); + } + } + let resolved_id = first_id.unwrap(); + let current = store.memory(&subject.id, &resolved_id).unwrap().unwrap(); + let mut resolved = draft("resolved", "no longer active"); + resolved.kind = current.kind; + resolved.state = MemoryState::Resolved; + store + .revise_memory(&subject.id, &resolved_id, current.revision, resolved) + .unwrap(); + + let first = store.prepare_surface_generation(&subject.id).unwrap(); + let second = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(first.materials, second.materials); + assert_eq!(first.active_memory_count, 53); + assert_eq!(first.materials.len(), SURFACE_TOTAL_MATERIAL_LIMIT); + assert!( + first + .materials + .iter() + .all(|item| item.memory_id != resolved_id) + ); + assert!(first.materials.iter().all(|item| item.body_md != "foreign")); + for kind in &kinds { + assert!( + first + .materials + .iter() + .filter(|item| &item.kind == kind) + .count() + <= SURFACE_PER_KIND_LIMIT + ); + } + let serialized = serde_json::to_vec(&first.materials).unwrap(); + assert!(estimated_tokens_for_bytes(serialized.len()) <= SURFACE_MATERIAL_TOKEN_BUDGET); + } + + #[test] + fn surface_states_validation_races_and_idempotency_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ungenerated + ); + + let empty_generation = store.prepare_surface_generation(&subject.id).unwrap(); + let empty = store + .publish_surface_generation(&subject.id, &empty_generation.id, Vec::new()) + .unwrap(); + assert!(empty.body_md.is_empty()); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ready + ); + + let memory = store + .create_memory(&subject.id, draft("Current constraint", "confirmed")) + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Stale + ); + let stale_generation = store.prepare_surface_generation(&subject.id).unwrap(); + let invalid = store.publish_surface_generation( + &subject.id, + &stale_generation.id, + vec![SurfacePoint { + body_md: "Invented".into(), + memory_refs: vec![MemoryRevisionRef { + memory_id: "foreign-memory".into(), + revision: 1, + }], + }], + ); + assert!(matches!( + invalid, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + store + .fail_surface_generation(&subject.id, &stale_generation.id, "invalid_output") + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Failed + ); + assert_eq!( + store + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .revision, + 1, + "surface failure must not roll back confirmed Memory" + ); + + let raced = store.prepare_surface_generation(&subject.id).unwrap(); + store + .revise_memory( + &subject.id, + &memory.id, + 1, + draft("Corrected constraint", "correction"), + ) + .unwrap(); + assert!(matches!( + store.publish_surface_generation( + &subject.id, + &raced.id, + vec![SurfacePoint { + body_md: "Old wording".into(), + memory_refs: vec![MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1, + }], + }], + ), + Err(SubjektivError::SurfaceGenerationConflict(_)) + )); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Stale + ); + + let current = store.prepare_surface_generation(&subject.id).unwrap(); + let current_ref = MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 2, + }; + let points = vec![SurfacePoint { + body_md: "Keep the corrected constraint, including its condition.".into(), + memory_refs: vec![current_ref.clone()], + }]; + let published = store + .publish_surface_generation(&subject.id, ¤t.id, points.clone()) + .unwrap(); + let retry = store + .publish_surface_generation(&subject.id, ¤t.id, points) + .unwrap(); + assert_eq!(published.id, retry.id); + let resident = store.resident_surface(&subject.id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Ready); + assert_eq!(resident.snapshot.unwrap().memory_refs, vec![current_ref]); + } + + #[test] + fn active_memory_that_cannot_fit_is_a_failure_not_a_normal_empty_surface() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let mut oversized = draft("oversized", "fixture"); + oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET + 1) * 4); + store.create_memory(&subject.id, oversized).unwrap(); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.active_memory_count, 1); + assert!(generation.materials.is_empty()); + assert!( + store + .publish_surface_generation(&subject.id, &generation.id, Vec::new()) + .is_err() + ); + store + .fail_surface_generation(&subject.id, &generation.id, "input_budget_exhausted") + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Failed + ); + } + + #[test] + fn concurrent_surface_generations_cannot_overwrite_one_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Concurrent constraint", "fixture")) + .unwrap(); + let first = store.prepare_surface_generation(&subject.id).unwrap(); + let second = store.prepare_surface_generation(&subject.id).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let mut joins = Vec::new(); + for (generation, body) in [(first, "first"), (second, "second")] { + let store = store.clone(); + let subject_id = subject.id.clone(); + let memory_id = memory.id.clone(); + let barrier = Arc::clone(&barrier); + joins.push(thread::spawn(move || { + barrier.wait(); + store.publish_surface_generation( + &subject_id, + &generation.id, + vec![SurfacePoint { + body_md: body.into(), + memory_refs: vec![MemoryRevisionRef { + memory_id, + revision: 1, + }], + }], + ) + })); + } + let results = joins + .into_iter() + .map(|join| join.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + Err(SubjektivError::SurfaceGenerationConflict(_)) + )) + .count(), + 1 + ); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ready + ); + } + #[test] fn reopen_and_backup_restore_preserve_exact_history() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md new file mode 100644 index 000000000..41d46f311 --- /dev/null +++ b/docs/design/subjektiv-surface.md @@ -0,0 +1,46 @@ +# Subject Memory surface generation + +The subject Memory surface is a bounded start-time context derived from confirmed, revisioned Memory. It is not another Memory authority and is never used as extraction or consolidation input. + +## Fixed policy + +The Backend captures one generation from current `active` revisions for exactly one subject. Staging records, Session history, historical revisions, and prior surfaces are not queried. + +Selection is deterministic: + +1. categories use the fixed order `preference`, `constraint`, `decision`, `working_assumption`, `open_question`, `lesson`; +2. each category is ordered by `updated_at DESC, memory_id ASC`; +3. at most 8 records are retained per category; +4. records are allocated round-robin by category, up to 24 records total; +5. the serialized material payload uses the repository's provider-independent token estimate, `ceil(UTF-8 bytes / 4)`, and is capped at 10,000 estimated tokens; the complete editor-input policy budget is 12,000 tokens. + +The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A store-generation conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate. + +Every editor point carries one or more exact `{memory_id, revision}` references. The Host checks the output shape, non-empty grounding, budget, subject scope, membership in the captured materials, and current store generation. These checks prove reference existence and freshness only; they do not claim semantic correctness. + +Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. + +Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Worker prompt materialization remains a start/restore boundary and does not rewrite recorded history. + +## Semantic fixture and verification + +Use a test subject with these active materials: + +- constraint: “Never send credentials to tools.” (`staleness`: “Until credential brokering is redesigned.”) +- constraint: “A trusted broker may deliver short-lived credentials to the Git provider.” +- decision: “Publish only the Ticket work branch; never force-push.” +- working assumption: “The provider resolves selectors after a normal push.” +- open question: “Does the provider preserve approval after target-only movement?” +- lesson: “Checking only the local branch previously produced non-reviewable evidence.” +- duplicate lesson: “Local HEAD alone is not immutable review evidence.” + +A valid review fixture must: + +- retain “never” and the credential-redesign expiry condition; +- present the apparently conflicting broker constraint without silently declaring one side invalid; +- preserve the target-only-movement item as an open question rather than a decision; +- combine the two review-evidence lessons only if both meanings remain; +- cite only exact refs supplied with those materials; +- distinguish mechanical citation validation from a human/model semantic review of the wording. + +The repository tests exercise deterministic bounds, subject isolation, active-current-only selection, output/reference rejection, empty/un-generated/failed/stale states, generation races, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index 271420d6c..13d8fc12c 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -24,6 +24,7 @@ internalBackendJobSystem = import "./internal/backend_job_system.md"; internalTicketItemChecker = import "./internal/ticket_item_checker.md"; internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md"; internalSubjektivMemoryConsolidationSystem = import "./internal/subjektiv_memory_consolidation_system.md"; +internalSubjektivMemorySurfaceSystem = import "./internal/subjektiv_memory_surface_system.md"; internalMemoryExtractSystem = import "./internal/memory_extract_system.md"; internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md"; internalNotifyWrapper = import "./internal/notify_wrapper.md"; @@ -62,6 +63,7 @@ in ticket_item_checker = internalTicketItemChecker.content; memory_consolidation_system = internalMemoryConsolidationSystem.content; subjektiv_memory_consolidation_system = internalSubjektivMemoryConsolidationSystem.content; + subjektiv_memory_surface_system = internalSubjektivMemorySurfaceSystem.content; memory_extract_system = internalMemoryExtractSystem.content; workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content; notify_wrapper = internalNotifyWrapper.content; diff --git a/resources/prompts/internal/subjektiv_memory_consolidation_system.md b/resources/prompts/internal/subjektiv_memory_consolidation_system.md index b04cef77b..7cb150dc7 100644 --- a/resources/prompts/internal/subjektiv_memory_consolidation_system.md +++ b/resources/prompts/internal/subjektiv_memory_consolidation_system.md @@ -26,7 +26,7 @@ Rules: - `source_candidate_ids` are host-derived from the applied candidate. Do not attempt to author them. - `derived_from` must contain only exact existing Memory revision refs returned by reads. - Applied responses return affected refs with `{memory_id, revision, operation}`. `duplicate` and `already_covered` may reference exact existing revisions; `discarded` and `invalid` require a concrete reason. -- Surface/snapshot generation is deferred. `surface_dirty: true` reports that a later process should rebuild it; never treat surface generation failure as failure of an already returned committed decision. +- Surface generation is a separate clean-context Host lifecycle that runs once after this consolidation turn commits. `surface_dirty: true` means confirmed Memory changed; never treat later surface generation failure as failure of an already returned committed decision. - An empty model response or transport failure is not a candidate disposition. Only a successful `MemoryApplyCandidate` response closes a candidate. Valid non-applied close actions are `discarded`, `invalid`, `duplicate`, and `already_covered`. Always provide a specific reason. diff --git a/resources/prompts/internal/subjektiv_memory_surface_system.md b/resources/prompts/internal/subjektiv_memory_surface_system.md new file mode 100644 index 000000000..d403fb5fb --- /dev/null +++ b/resources/prompts/internal/subjektiv_memory_surface_system.md @@ -0,0 +1,32 @@ +# Subject Memory surface editor + +You are a clean-context editor for one subject's confirmed Memory surface. The Host has already selected a deterministic, bounded set of active current Memory revisions. Those materials are the only facts and references you may use. + +Answer this fixed question: + +> この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。 + +Call `SubmitMemorySurface` exactly once with `{ "points": [...] }`. Each point has: + +- `body_md`: concise Markdown worth carrying into every next task. +- `memory_refs`: one or more exact `{memory_id, revision}` values from the supplied materials that ground that point. + +Rules: + +- Do not add a fact, decision, identifier, or reference absent from the materials. +- Preserve conditions, negation, reservations, and stated staleness/expiry conditions. +- Similar material may be combined only when the combined wording preserves each source's meaning. +- Do not resolve contradictions. State conflicting constraints or assumptions as unresolved when both matter. +- This is not a complete index. Keep only continuously useful constraints/preferences, active decisions/working assumptions, open questions, and recurrence-preventing lessons. +- Every point needs at least one supplied exact reference. Never invent a citation. +- Reference existence and subject scope are mechanically checked. That check is not semantic verification; you remain responsible for faithful wording. +- Stay within the supplied body token budget. The Host uses the documented provider-independent UTF-8 byte estimate. +- If the materials array is empty, submit an empty points array without inventing content. +- A rejected tool call may be corrected, but this Worker is bounded to three model turns total (initial attempt plus at most two correction turns). + +## Language + +- `language`: `{{language}}` +- Write the surface in this language. +- Preserve literal identifiers, paths, commands, branch names, issue IDs, tool names, model names, and quoted user/system text as-is. +- If the configured language is unclear, use English. From 504ea00ae3aa42a9894ff1169199642f89ecf583 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 14:01:27 +0900 Subject: [PATCH 16/31] fix: harden memory surface generation --- .../builtin/memory_surface_lifecycle.rs | 590 +++++++++++++++++- .../feature/builtin/memory_surface_output.rs | 24 +- crates/workspace-server/src/subjektiv.rs | 289 ++++++++- docs/design/subjektiv-surface.md | 8 +- 4 files changed, 843 insertions(+), 68 deletions(-) diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index db7b3f9be..ca7c15d15 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -1,9 +1,12 @@ +use std::pin::Pin; use std::sync::Arc; use std::time::Duration; -use agen::llm_client::LlmClient; +use agen::llm_client::event::Event as LlmEvent; +use agen::llm_client::{ClientError, LlmClient, Request}; use arc_swap::ArcSwap; use async_trait::async_trait; +use futures_util::Stream; use crate::PromptCatalog; use crate::Scope; @@ -13,7 +16,7 @@ use crate::feature::background::{ }; use crate::feature::builtin::memory::WorkspaceMemoryBackendError; use crate::feature::builtin::memory_surface_output::{ - MemorySurfaceOutputFeature, MemorySurfaceOutputState, + MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition, }; use crate::feature::{ BackgroundTaskDeclaration, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, @@ -31,6 +34,7 @@ const TASK_NAME: &str = "subjektiv-memory-surface"; const TASK_TIMEOUT: Duration = Duration::from_secs(300); const MAX_GENERATION_ATTEMPTS: usize = 2; const EDITOR_MAX_TURNS: u32 = 3; +const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。"; #[derive(Clone)] pub(crate) struct SubjektivSurfaceLifecycleFeature { @@ -46,6 +50,45 @@ struct SubjektivSurfaceLifecycleTask { workspace_context: WorkerWorkspaceContext, } +const INPUT_BUDGET_ERROR_MARKER: &str = "surface_editor_input_budget_exceeded"; + +struct InputBudgetLlmClient { + inner: Box, + input_token_budget: usize, +} + +impl Clone for InputBudgetLlmClient { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone_boxed(), + input_token_budget: self.input_token_budget, + } + } +} + +#[async_trait] +impl LlmClient for InputBudgetLlmClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + let estimated = estimated_editor_request_tokens(&request) + .map_err(|error| ClientError::Config(error.to_string()))?; + if estimated > self.input_token_budget { + return Err(ClientError::Config(format!( + "{INPUT_BUDGET_ERROR_MARKER}: request estimate {estimated} exceeds {}", + self.input_token_budget + ))); + } + self.inner.stream(request).await + } +} + impl SubjektivSurfaceLifecycleFeature { pub(crate) fn from_manifest( lifecycle_enabled: bool, @@ -107,6 +150,27 @@ impl FeatureModule for SubjektivSurfaceLifecycleFeature { } } +struct SurfaceEditorFailure { + error: HookError, + reason_code: &'static str, +} + +impl SurfaceEditorFailure { + fn input_budget(error: HookError) -> Self { + Self { + error, + reason_code: "input_budget_exhausted", + } + } + + fn editor(error: HookError) -> Self { + Self { + error, + reason_code: "editor_failed", + } + } +} + #[async_trait] impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask { async fn run( @@ -138,10 +202,10 @@ impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask { } else { match self.edit_surface(&generation, cancellation.clone()).await { Ok(points) => points, - Err(error) => { - self.record_failure(&generation.generation_id, "editor_failed") + Err(failure) => { + self.record_failure(&generation.generation_id, failure.reason_code) .await; - tracing::warn!(%error, "subject Memory surface editor failed"); + tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed"); return Ok(()); } } @@ -192,7 +256,7 @@ impl SubjektivSurfaceLifecycleTask { &self, generation: &server_api::SubjektivSurfacePrepareResponse, cancellation: BackgroundTaskCancellation, - ) -> Result, HookError> { + ) -> Result, SurfaceEditorFailure> { let language = self .manifest .feature @@ -200,27 +264,25 @@ impl SubjektivSurfaceLifecycleTask { .workspace_settings() .map(|settings| settings.language) .ok_or_else(|| { - HookError::new( + SurfaceEditorFailure::editor(HookError::new( HookErrorCategory::Internal, "surface editor requires bound Workspace Memory settings", - ) + )) })?; let system_prompt = self .prompts .load_full() .subjektiv_memory_surface_system(&language) - .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; - let input = serde_json::to_string_pretty(&serde_json::json!({ - "question": "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。", - "body_token_budget": generation.body_token_budget, - "input_token_budget": generation.input_token_budget, - "materials": generation.materials, - })) - .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; - let output_state = MemorySurfaceOutputState::new( - generation.materials.clone(), - generation.body_token_budget, - ); + .map_err(|error| { + SurfaceEditorFailure::editor(HookError::new( + HookErrorCategory::Internal, + error.to_string(), + )) + })?; + let (input, editor_materials) = bounded_editor_input(system_prompt.as_str(), generation) + .map_err(SurfaceEditorFailure::input_budget)?; + let output_state = + MemorySurfaceOutputState::new(editor_materials, generation.body_token_budget); let features = FeatureRegistryBuilder::new() .with_module(MemorySurfaceOutputFeature::new(output_state.clone())); let cancellation_observer = cancellation.clone(); @@ -237,7 +299,10 @@ impl SubjektivSurfaceLifecycleTask { run_id: uuid::Uuid::now_v7(), }, manifest: self.manifest.clone(), - client: self.client.clone_boxed(), + client: Box::new(InputBudgetLlmClient { + inner: self.client.clone_boxed(), + input_token_budget: generation.input_token_budget, + }), system_prompt, input, cache_key: None, @@ -256,17 +321,23 @@ impl SubjektivSurfaceLifecycleTask { ) .await; if let Err(error) = result { - return Err(HookError::new( - HookErrorCategory::Internal, - format!("surface editor Internal Worker failed: {}", error.source), - )); + let message = format!("surface editor Internal Worker failed: {}", error.source); + let error = HookError::new(HookErrorCategory::Internal, message.clone()); + return Err(if message.contains(INPUT_BUDGET_ERROR_MARKER) { + SurfaceEditorFailure::input_budget(error) + } else { + SurfaceEditorFailure::editor(error) + }); } - output_state.submitted().ok_or_else(|| { - HookError::new( - HookErrorCategory::Internal, - "surface editor finished without valid structured output", - ) - }) + output_state + .submitted() + .ok_or_else(|| { + HookError::new( + HookErrorCategory::Internal, + "surface editor finished without valid structured output", + ) + }) + .map_err(SurfaceEditorFailure::editor) } async fn record_failure(&self, generation_id: &str, reason_code: &str) { @@ -283,6 +354,463 @@ impl SubjektivSurfaceLifecycleTask { } } +fn bounded_editor_input( + system_prompt: &str, + generation: &server_api::SubjektivSurfacePrepareResponse, +) -> Result<(String, Vec), HookError> { + let mut materials = generation.materials.clone(); + loop { + let input = serde_json::to_string(&serde_json::json!({ + "question": EDITOR_QUESTION, + "body_token_budget": generation.body_token_budget, + "input_token_budget": generation.input_token_budget, + "materials": &materials, + })) + .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; + let request = Request::new() + .system(system_prompt) + .user(input.clone()) + .tool(submit_llm_tool_definition()); + let estimated_tokens = estimated_editor_request_tokens(&request)?; + if estimated_tokens <= generation.input_token_budget { + if materials.is_empty() && !generation.materials.is_empty() { + return Err(HookError::new( + HookErrorCategory::Internal, + format!( + "surface editor fixed prompt and material overhead left no grounded material within the {} token input budget", + generation.input_token_budget + ), + )); + } + return Ok((input, materials)); + } + if materials.pop().is_none() { + return Err(HookError::new( + HookErrorCategory::Internal, + format!( + "surface editor fixed prompt, JSON framing, and tool schema exceed the {} token input budget (estimated {estimated_tokens})", + generation.input_token_budget + ), + )); + } + } +} + +fn estimated_editor_request_tokens(request: &Request) -> Result { + // Serialize the actual prompt-bearing Agen request fields. This is the + // stable provider-independent boundary; provider-specific HTTP envelopes + // are intentionally outside the configured editor-input budget. + let request = serde_json::to_vec(&serde_json::json!({ + "system_prompt": &request.system_prompt, + "items": &request.items, + "tools": &request.tools, + })) + .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; + Ok(request.len().saturating_add(3) / 4) +} + fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError { HookError::new(HookErrorCategory::Internal, error.to_string()) } + +#[cfg(test)] +mod tests { + use std::pin::Pin; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; + use agen::llm_client::{ClientError, Request}; + use futures::Stream; + use memory::extract::CandidateKind; + + use super::*; + use crate::feature::FeatureId; + use crate::feature::background::FeatureBackgroundTaskRegistryBuilder; + use crate::hook::HookInvocationContext; + use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + + fn material(id: &str, body_bytes: usize) -> server_api::SubjektivSurfaceMaterial { + server_api::SubjektivSurfaceMaterial { + memory_id: id.into(), + revision: 1, + kind: CandidateKind::Constraint, + body_md: "x".repeat(body_bytes), + why_useful: "budget fixture".into(), + staleness: None, + } + } + + fn generation( + input_token_budget: usize, + materials: Vec, + ) -> server_api::SubjektivSurfacePrepareResponse { + server_api::SubjektivSurfacePrepareResponse { + generation_id: "generation-1".into(), + store_revision: 1, + active_memory_count: materials.len(), + materials, + body_token_budget: 1_024, + input_token_budget, + per_kind_limit: 8, + total_material_limit: 24, + } + } + + fn editor_request(system_prompt: &str, input: &str) -> Request { + Request::new() + .system(system_prompt) + .user(input) + .tool(submit_llm_tool_definition()) + } + + #[derive(Debug)] + struct SurfaceWorkspaceClient { + conflict_count: usize, + input_token_budget: usize, + prepare_calls: AtomicUsize, + publish_calls: AtomicUsize, + failure_reasons: Mutex>, + } + + impl SurfaceWorkspaceClient { + fn new(conflict_count: usize) -> Self { + Self { + conflict_count, + input_token_budget: 12_000, + prepare_calls: AtomicUsize::new(0), + publish_calls: AtomicUsize::new(0), + failure_reasons: Mutex::new(Vec::new()), + } + } + + fn with_input_token_budget(input_token_budget: usize) -> Self { + Self { + input_token_budget, + ..Self::new(0) + } + } + + fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse { + WorkspaceResponse { + status: 200, + body: serde_json::to_string(&value).unwrap(), + } + } + } + + impl WorkspaceClient for SurfaceWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-1") + } + + fn kind(&self) -> &str { + "surface-lifecycle-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let request: server_api::SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap_or_default()) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + match request.operation { + server_api::SubjektivMemoryBackendOperation::PrepareSurface(_) => { + let call = self.prepare_calls.fetch_add(1, Ordering::SeqCst) + 1; + let mut prepared = + generation(self.input_token_budget, vec![material("memory-1", 32)]); + prepared.generation_id = format!("generation-{call}"); + prepared.store_revision = call as u64; + Ok(Self::response( + server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared), + )) + } + server_api::SubjektivMemoryBackendOperation::PublishSurface(input) => { + assert_eq!(input.points.len(), 1); + let call = self.publish_calls.fetch_add(1, Ordering::SeqCst) + 1; + if call <= self.conflict_count { + return Ok(WorkspaceResponse { + status: 409, + body: "store generation moved".into(), + }); + } + Ok(Self::response( + server_api::SubjektivMemoryBackendResponse::SurfacePublished( + server_api::SubjektivSurfacePublishResponse { + snapshot_id: format!("surface-{call}"), + built_from_store_revision: call as u64, + empty: false, + }, + ), + )) + } + server_api::SubjektivMemoryBackendOperation::FailSurface(input) => { + self.failure_reasons.lock().unwrap().push(input.reason_code); + Ok(Self::response( + server_api::SubjektivMemoryBackendResponse::SurfaceFailed( + server_api::SubjektivSurfaceFailureResponse { + store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64, + status: "failed".into(), + }, + ), + )) + } + _ => Err(WorkspaceClientError::Unavailable( + "surface lifecycle test only accepts surface operations".into(), + )), + } + } + } + + #[derive(Clone)] + struct SurfaceEditorClient { + calls: Arc, + } + + #[async_trait] + impl LlmClient for SurfaceEditorClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + async fn stream( + &self, + _request: Request, + ) -> Result> + Send>>, ClientError> + { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let events = if call % 2 == 0 { + vec![ + LlmEvent::tool_use_start(0, format!("submit-{call}"), "SubmitMemorySurface"), + LlmEvent::tool_input_delta( + 0, + serde_json::json!({ + "points": [{ + "body_md": "- Keep the confirmed constraint.", + "memory_refs": [{"memory_id": "memory-1", "revision": 1}] + }] + }) + .to_string(), + ), + LlmEvent::tool_use_stop(0), + LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] + } else { + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "submitted"), + LlmEvent::text_block_stop(0, None), + LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] + }; + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } + } + + fn test_manifest() -> WorkerManifest { + let mut manifest = WorkerManifest::from_toml( + r#" +[worker] +name = "surface-lifecycle-test" +scope = "main" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/surface-lifecycle-test" +permission = "write" +"#, + ) + .unwrap(); + manifest.feature.memory.profile.enabled = true; + manifest + .feature + .memory + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".into(), + settings_revision: 1, + language: "English".into(), + }) + .unwrap(); + manifest + } + + fn test_task( + workspace_client: Arc, + calls: Arc, + ) -> SubjektivSurfaceLifecycleTask { + SubjektivSurfaceLifecycleTask { + workspace_client, + manifest: test_manifest(), + client: Box::new(SurfaceEditorClient { calls }), + prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + workspace_context: WorkerWorkspaceContext::no_workspace(), + } + } + + async fn run_task(task: SubjektivSurfaceLifecycleTask) { + let declaration = BackgroundTaskDeclaration::worker_managed( + TASK_NAME, + "surface lifecycle integration test", + ); + let mut spec = BackgroundTaskSpec::single_flight(declaration, Duration::from_secs(5)); + spec.trigger = BackgroundTaskTrigger::RunCommitted; + let mut builder = FeatureBackgroundTaskRegistryBuilder::default(); + builder + .register(FeatureId::builtin("surface-lifecycle-test"), spec, task) + .unwrap(); + let registry = builder.build(); + registry + .start_run_committed(HookInvocationContext { + worker_id: "worker-1".into(), + session_id: "session-1".into(), + session_revision: 1, + run_id: Some("run-1".into()), + ..Default::default() + }) + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if !registry.diagnostics().is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("surface lifecycle task should finish"); + registry.shutdown().await.unwrap(); + } + + #[test] + fn editor_input_budget_counts_prompt_json_question_and_tool_schema() { + let generation = generation( + 4_000, + vec![material("memory-1", 9_000), material("memory-2", 9_000)], + ); + let (input, selected) = bounded_editor_input("system policy", &generation).unwrap(); + + assert_eq!(selected.len(), 1, "deterministically trims the tail"); + assert_eq!(selected[0].memory_id, "memory-1"); + assert!(input.contains(EDITOR_QUESTION)); + assert!( + estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap() + <= generation.input_token_budget + ); + let materials_only_tokens = (serde_json::to_vec(&selected) + .unwrap() + .len() + .saturating_add(3)) + / 4; + assert!( + estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap() + > materials_only_tokens, + "full accounting must include framing and the SubmitMemorySurface schema" + ); + } + + #[tokio::test] + async fn actual_editor_request_is_rejected_before_provider_when_over_budget() { + let calls = Arc::new(AtomicUsize::new(0)); + let client = InputBudgetLlmClient { + inner: Box::new(SurfaceEditorClient { + calls: calls.clone(), + }), + input_token_budget: 32, + }; + let request = editor_request("system policy", &"x".repeat(1_000)); + + let error = match client.stream(request).await { + Err(error) => error, + Ok(_) => panic!("over-budget request reached the provider"), + }; + assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn correction_turns_remain_subject_to_editor_input_budget() { + let calls = Arc::new(AtomicUsize::new(0)); + let client = InputBudgetLlmClient { + inner: Box::new(SurfaceEditorClient { + calls: calls.clone(), + }), + input_token_budget: 2_000, + }; + let _stream = client + .stream(editor_request("system policy", "small initial request")) + .await + .unwrap(); + let request = + editor_request("system policy", "small initial request").assistant("x".repeat(12_000)); + let error = match client.stream(request).await { + Err(error) => error, + Ok(_) => panic!("over-budget request reached the provider"), + }; + + assert!(error.to_string().contains(INPUT_BUDGET_ERROR_MARKER)); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn lifecycle_rebuilds_once_after_a_store_generation_conflict() { + let workspace = Arc::new(SurfaceWorkspaceClient::new(1)); + let editor_calls = Arc::new(AtomicUsize::new(0)); + run_task(test_task(workspace.clone(), editor_calls.clone())).await; + + assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2); + assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2); + assert!(workspace.failure_reasons.lock().unwrap().is_empty()); + assert_eq!(editor_calls.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn lifecycle_bounds_conflict_retries_and_records_terminal_failure() { + let workspace = Arc::new(SurfaceWorkspaceClient::new(MAX_GENERATION_ATTEMPTS)); + let editor_calls = Arc::new(AtomicUsize::new(0)); + run_task(test_task(workspace.clone(), editor_calls.clone())).await; + + assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2); + assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2); + assert_eq!(editor_calls.load(Ordering::SeqCst), 4); + assert_eq!( + workspace.failure_reasons.lock().unwrap().as_slice(), + ["publish_failed"] + ); + } + + #[tokio::test] + async fn lifecycle_records_input_budget_exhaustion_without_provider_or_publish() { + let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1)); + let editor_calls = Arc::new(AtomicUsize::new(0)); + run_task(test_task(workspace.clone(), editor_calls.clone())).await; + + assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1); + assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0); + assert_eq!(editor_calls.load(Ordering::SeqCst), 0); + assert_eq!( + workspace.failure_reasons.lock().unwrap().as_slice(), + ["input_budget_exhausted"] + ); + } + + #[test] + fn editor_input_budget_never_relabels_trimmed_nonempty_materials_as_empty() { + let generation = generation(1, vec![material("memory-1", 32)]); + let error = bounded_editor_input("system policy", &generation).unwrap_err(); + assert!(error.to_string().contains("input budget")); + } +} diff --git a/crates/worker/src/feature/builtin/memory_surface_output.rs b/crates/worker/src/feature/builtin/memory_surface_output.rs index 4b3233364..2527d0cd5 100644 --- a/crates/worker/src/feature/builtin/memory_surface_output.rs +++ b/crates/worker/src/feature/builtin/memory_surface_output.rs @@ -140,13 +140,27 @@ impl FeatureModule for MemorySurfaceOutputFeature { } } +fn submit_input_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(SubmitMemorySurfaceParams)) + .unwrap_or_else(|_| serde_json::json!({})) +} + +pub(crate) fn submit_llm_tool_definition() -> agen::llm_client::ToolDefinition { + agen::llm_client::ToolDefinition::new(SUBMIT_TOOL) + .description(SUBMIT_DESCRIPTION) + .input_schema(submit_input_schema()) +} + fn submit_definition(state: MemorySurfaceOutputState) -> ToolDefinition { Arc::new(move || { - let schema = serde_json::to_value(schemars::schema_for!(SubmitMemorySurfaceParams)) - .unwrap_or_else(|_| serde_json::json!({})); - let meta = ToolMeta::new(SUBMIT_TOOL) - .description(SUBMIT_DESCRIPTION) - .input_schema(schema); + let definition = submit_llm_tool_definition(); + let meta = ToolMeta::new(definition.name) + .description( + definition + .description + .expect("SubmitMemorySurface description is fixed"), + ) + .input_schema(definition.input_schema); let tool: Arc = Arc::new(SubmitMemorySurfaceTool { state: state.clone(), }); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 3b5a8708a..749ea31b1 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -917,19 +917,9 @@ INSERT INTO surface_generation_state ( ) SELECT subjects.subject_id, subjects.store_revision, - CASE WHEN EXISTS ( - SELECT 1 FROM surface_snapshots current - WHERE current.subject_id = subjects.subject_id - AND current.built_from_store_revision = subjects.store_revision - ) THEN 'ready' ELSE 'dirty' END, - ( - SELECT current.snapshot_id FROM surface_snapshots current - WHERE current.subject_id = subjects.subject_id - AND current.built_from_store_revision = subjects.store_revision - ORDER BY current.created_at ASC, current.snapshot_id ASC - LIMIT 1 - ), + 'dirty', NULL, + 'legacy_snapshot_requires_regeneration', subjects.updated_at FROM subjects WHERE EXISTS ( @@ -1729,7 +1719,7 @@ impl SubjektivStore { } let (body_md, memory_refs) = validate_surface_points(&materials, &points)?; if let Some(existing) = - surface_snapshot_for_revision(transaction, subject_id, store_revision)? + ready_surface_snapshot_for_revision(transaction, subject_id, store_revision)? { if existing.body_md == body_md && existing.memory_refs == memory_refs { return Ok(existing); @@ -1959,29 +1949,85 @@ fn select_surface_materials( } } - let mut selected = Vec::new(); - let mut payload_bytes = 2usize; // JSON array delimiters. + let populated_kind_count = buckets.iter().filter(|bucket| !bucket.is_empty()).count(); + let per_kind_token_budget = SURFACE_MATERIAL_TOKEN_BUDGET / populated_kind_count.max(1); + let mut ordered = Vec::new(); for offset in 0..SURFACE_PER_KIND_LIMIT { - for bucket in &buckets { - let Some(material) = bucket.get(offset) else { - continue; - }; - if selected.len() == SURFACE_TOTAL_MATERIAL_LIMIT { - return Ok((active_memory_count, selected)); + for (bucket_index, bucket) in buckets.iter().enumerate() { + if let Some(material) = bucket.get(offset) { + ordered.push((bucket_index, material.clone())); } - let material_bytes = serde_json::to_vec(material)?.len().saturating_add(1); - if estimated_tokens_for_bytes(payload_bytes.saturating_add(material_bytes)) - > SURFACE_MATERIAL_TOKEN_BUDGET - { - continue; - } - payload_bytes = payload_bytes.saturating_add(material_bytes); - selected.push(material.clone()); } } + + let mut accepted = vec![false; ordered.len()]; + let mut selected_count = 0usize; + let mut deferred = Vec::new(); + for (index, (bucket_index, material)) in ordered.iter().enumerate() { + if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { + break; + } + let kind_materials = ordered + .iter() + .zip(&accepted) + .filter(|((selected_bucket, _), accepted)| { + **accepted && selected_bucket == bucket_index + }) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + let all_materials = ordered + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + if serialized_material_tokens(kind_materials)? <= per_kind_token_budget + && serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET + { + accepted[index] = true; + selected_count += 1; + } else { + deferred.push(index); + } + } + + // Category shares are fairness reservations, not hard ceilings. Reclaim + // unused shares in the same canonical order after every populated category + // had its first opportunity. + for index in deferred { + if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { + break; + } + let material = &ordered[index].1; + let all_materials = ordered + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + if serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET { + accepted[index] = true; + selected_count += 1; + } + } + + let selected = ordered + .into_iter() + .zip(accepted) + .filter_map(|((_, material), accepted)| accepted.then_some(material)) + .collect(); Ok((active_memory_count, selected)) } +fn serialized_material_tokens<'a>( + materials: impl IntoIterator, +) -> Result { + let materials = materials.into_iter().collect::>(); + Ok(estimated_tokens_for_bytes( + serde_json::to_vec(&materials)?.len(), + )) +} + fn surface_kind_index(kind: &CandidateKind) -> usize { match kind { CandidateKind::Preference => 0, @@ -2053,6 +2099,28 @@ fn validate_surface_points( Ok((body_md, refs)) } +fn ready_surface_snapshot_for_revision( + transaction: &Transaction<'_>, + subject_id: &str, + store_revision: u64, +) -> Result> { + let raw = transaction + .query_row( + "SELECT snapshots.snapshot_json + FROM surface_generation_state state + JOIN surface_snapshots snapshots + ON snapshots.subject_id = state.subject_id + AND snapshots.snapshot_id = state.snapshot_id + WHERE state.subject_id = ?1 + AND state.store_revision = ?2 + AND state.status = 'ready'", + params![subject_id, to_i64(store_revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_surface_snapshot(&raw)).transpose() +} + fn surface_snapshot_for_revision( transaction: &Transaction<'_>, subject_id: &str, @@ -3237,6 +3305,19 @@ mod tests { "create subjektiv subject memory store", create_schema, )]; + static V3_MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), + ]; fn role() -> SubjectRole { SubjectRole::new("workspace_companion").unwrap() @@ -3458,6 +3539,90 @@ mod tests { ); } + #[test] + fn legacy_surface_snapshot_migrates_as_stale_until_regenerated() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let (subject_id, snapshot_id) = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V3_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + let subject = store.create_subject(role()).unwrap(); + let snapshot = SurfaceSnapshot { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: "legacy-surface".into(), + subject_id: subject.id.clone(), + body_md: "Legacy summary without generation-policy evidence.".into(), + memory_refs: Vec::new(), + built_from_store_revision: subject.store_revision, + created_at: now(), + }; + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO surface_snapshots ( + subject_id, snapshot_id, built_from_store_revision, + snapshot_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject.id, + snapshot.id, + to_i64(snapshot.built_from_store_revision)?, + serde_json::to_string(&snapshot)?, + snapshot.created_at + ], + )?; + transaction.execute( + "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) + VALUES (?1, ?2)", + params![subject.id, snapshot.id], + )?; + Ok(()) + }) + .unwrap(); + (subject.id, snapshot.id) + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let resident = store.resident_surface(&subject_id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Stale); + assert!(resident.snapshot.is_none()); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "migration retains the legacy snapshot as history without injecting it" + ); + + let generation = store.prepare_surface_generation(&subject_id).unwrap(); + let regenerated = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_ne!(regenerated.id, snapshot_id); + let ready = store.resident_surface(&subject_id).unwrap(); + assert_eq!(ready.availability, SurfaceAvailability::Ready); + assert_eq!(ready.snapshot.unwrap().id, regenerated.id); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "regeneration appends instead of mutating legacy history" + ); + let retried = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_eq!(retried.id, regenerated.id); + } + #[test] fn attributed_staging_is_atomic_and_retry_idempotent() { let temp = tempfile::tempdir().unwrap(); @@ -4812,6 +4977,72 @@ mod tests { assert!(estimated_tokens_for_bytes(serialized.len()) <= SURFACE_MATERIAL_TOKEN_BUDGET); } + #[test] + fn oversized_kind_cannot_starve_other_surface_categories() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + + let mut oversized = draft("oversized preference", "category fairness fixture"); + oversized.kind = CandidateKind::Preference; + oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET - 100) * 4); + store.create_memory(&subject.id, oversized).unwrap(); + + for kind in [ + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ] { + let mut value = draft(&format!("{kind:?}"), "category fairness fixture"); + value.kind = kind; + store.create_memory(&subject.id, value).unwrap(); + } + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + for kind in [ + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ] { + assert!( + generation.materials.iter().any(|item| item.kind == kind), + "one large category must not exclude {kind:?}" + ); + } + assert!( + estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) + <= SURFACE_MATERIAL_TOKEN_BUDGET + ); + } + + #[test] + fn surface_material_selection_reclaims_unused_category_budget() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + + let mut small = draft("small preference", "soft category budget fixture"); + small.kind = CandidateKind::Preference; + store.create_memory(&subject.id, small).unwrap(); + let mut large = draft("large constraint", "soft category budget fixture"); + large.kind = CandidateKind::Constraint; + large.body_md = "x".repeat(22_000); + store.create_memory(&subject.id, large).unwrap(); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.materials.len(), 2); + assert_eq!(generation.materials[0].kind, CandidateKind::Preference); + assert_eq!(generation.materials[1].kind, CandidateKind::Constraint); + assert!( + estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) + <= SURFACE_MATERIAL_TOKEN_BUDGET + ); + } + #[test] fn surface_states_validation_races_and_idempotency_fail_closed() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index 41d46f311..457e2841c 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -12,7 +12,9 @@ Selection is deterministic: 2. each category is ordered by `updated_at DESC, memory_id ASC`; 3. at most 8 records are retained per category; 4. records are allocated round-robin by category, up to 24 records total; -5. the serialized material payload uses the repository's provider-independent token estimate, `ceil(UTF-8 bytes / 4)`, and is capped at 10,000 estimated tokens; the complete editor-input policy budget is 12,000 tokens. +5. the 10,000-token material budget is initially divided equally across categories as a fairness reservation; unused shares are then reclaimed in the same canonical round-robin order, so one large category cannot exclude another non-empty category or strand usable capacity; +6. both category and aggregate material bounds use the provider-independent estimate `ceil(UTF-8 bytes / 4)`; +7. before invoking the editor, the Worker measures the actual normalized Agen request fields containing the rendered system prompt, conversation items (including the fixed question and material JSON), and the typed `SubmitMemorySurface` tool definition/schema against the 12,000-token complete-input budget. It deterministically removes tail materials until the initial request fits, fails rather than publishing a false empty surface if none fit, and guards every subsequent correction request before it reaches the provider. Provider-specific HTTP envelope bytes are outside this normalized request budget. The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A store-generation conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate. @@ -20,7 +22,7 @@ Every editor point carries one or more exact `{memory_id, revision}` references. Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. -Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Worker prompt materialization remains a start/restore boundary and does not rewrite recorded history. +Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Worker prompt materialization remains a start/restore boundary and does not rewrite recorded history. ## Semantic fixture and verification @@ -43,4 +45,4 @@ A valid review fixture must: - cite only exact refs supplied with those materials; - distinguish mechanical citation validation from a human/model semantic review of the wording. -The repository tests exercise deterministic bounds, subject isolation, active-current-only selection, output/reference rejection, empty/un-generated/failed/stale states, generation races, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. +The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/un-generated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. From 013f909b9b404b3f7f2652bc2f65888b0f39b4ce Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 14:27:52 +0900 Subject: [PATCH 17/31] fix: require explicit Workspace repository key --- crates/server-api/src/lib.rs | 11 ++ crates/tui/src/backend_workspace_picker.rs | 116 +++++++++++++++--- .../workspace-server/src/workspace_catalog.rs | 43 +++++-- crates/yoi/src/main.rs | 33 ++++- web/workspace/tests/workspace-catalog.test.ts | 31 ++++- 5 files changed, 206 insertions(+), 28 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 089d7c65c..6f0797f73 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -11186,6 +11186,17 @@ mod tests { })) .is_err() ); + assert!( + serde_json::from_value::(serde_json::json!({ + "operation_key": "workspace-create-1", + "display_name": "Workspace", + "repository": { + "uri": "/srv/repositories/platform" + } + })) + .is_err(), + "the API wire shape must not supply a default Repository key" + ); } #[test] diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index ace33d7e5..af917b668 100644 --- a/crates/tui/src/backend_workspace_picker.rs +++ b/crates/tui/src/backend_workspace_picker.rs @@ -11,7 +11,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use std::error::Error; -use std::io::{self, IsTerminal, Write}; +use std::io::{self, BufRead, IsTerminal, Write}; use std::time::{SystemTime, UNIX_EPOCH}; type PickerResult = Result>; @@ -179,18 +179,47 @@ fn prompt_create_request() -> PickerResult } fn prompt_create_request_inner() -> PickerResult> { - println!("Create Workspace (leave display name empty to cancel)"); - let display_name = prompt_line("Workspace display name: ")?; + let stdin = io::stdin(); + let stdout = io::stdout(); + prompt_create_request_with_io(&mut stdin.lock(), &mut stdout.lock()) +} + +fn prompt_create_request_with_io( + input: &mut impl BufRead, + output: &mut impl Write, +) -> PickerResult> { + writeln!( + output, + "Create Workspace (leave display name empty to cancel)" + )?; + let Some(display_name) = prompt_line(input, output, "Workspace display name: ")? else { + return Ok(None); + }; if display_name.is_empty() { return Ok(None); } - let uri = prompt_line("Initial repository absolute path/URI: ")?; + let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else { + return Ok(None); + }; if uri.is_empty() { - println!("Repository path/URI is required."); + writeln!(output, "Repository path/URI is required.")?; return Ok(None); } - let repository_key = prompt_line("Repository key [main]: ")?; - let default_ref = prompt_line("Default ref [repository default]: ")?; + let repository_key = loop { + let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")? + else { + return Ok(None); + }; + if repository_key.is_empty() { + writeln!(output, "Repository key is required.")?; + continue; + } + break repository_key; + }; + let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")? + else { + return Ok(None); + }; let operation_key = format!( "tui-workspace-create-{}-{}", std::process::id(), @@ -204,27 +233,37 @@ fn prompt_create_request_inner() -> PickerResult PickerResult { - print!("{prompt}"); - io::stdout().flush()?; +fn prompt_line( + input: &mut impl BufRead, + output: &mut impl Write, + prompt: &str, +) -> PickerResult> { + write!(output, "{prompt}")?; + output.flush()?; let mut value = String::new(); - io::stdin().read_line(&mut value)?; - Ok(value.trim().to_string()) + if input.read_line(&mut value)? == 0 { + return Ok(None); + } + Ok(Some(value.trim().to_string())) } #[cfg(test)] mod tests { use super::*; + use std::io::Cursor; + + fn prompt_create(input: &str) -> (Option, String) { + let mut input = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + let request = prompt_create_request_with_io(&mut input, &mut output).unwrap(); + (request, String::from_utf8(output).unwrap()) + } #[test] fn picker_actions_distinguish_switch_refresh_create_and_cancel() { @@ -237,4 +276,47 @@ mod tests { WorkspacePickerAction::Cancel ); } + + #[test] + fn workspace_creation_preserves_an_explicit_non_main_repository_key() { + let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n"); + let request = request.expect("complete input should create a request"); + + assert_eq!(request.display_name, "Platform"); + assert_eq!(request.repository.uri, "/srv/platform"); + assert_eq!(request.repository.repository_key, "platform"); + assert_eq!(request.repository.default_ref.as_deref(), Some("develop")); + assert!(output.contains("Repository key (required): ")); + assert!(!output.contains("Repository key [main]")); + } + + #[test] + fn workspace_creation_reprompts_for_blank_repository_keys() { + let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n"); + let request = request.expect("a later explicit key should create a request"); + + assert_eq!(request.repository.repository_key, "platform"); + assert_eq!(output.matches("Repository key is required.").count(), 2); + assert_eq!(output.matches("Repository key (required): ").count(), 3); + } + + #[test] + fn workspace_creation_accepts_explicit_main_repository_key() { + let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n"); + + assert_eq!( + request.unwrap().repository.repository_key, + "main", + "main remains valid when the creator enters it explicitly" + ); + } + + #[test] + fn workspace_creation_cancels_on_eof_instead_of_retrying_forever() { + let (request, output) = prompt_create("Workspace\n/srv/repository\n \n"); + + assert!(request.is_none()); + assert_eq!(output.matches("Repository key is required.").count(), 1); + assert_eq!(output.matches("Repository key (required): ").count(), 2); + } } diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index 3dee38110..ac98cae2e 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -296,7 +296,7 @@ mod tests { display_name: "Workspace A".to_string(), repository: InitialRepositoryIntent { uri: repository.path().display().to_string(), - repository_key: "main".to_string(), + repository_key: "platform".to_string(), default_ref: None, }, }; @@ -322,13 +322,11 @@ mod tests { assert_eq!(signing_identity.algorithm, "ed25519"); assert!(signing_identity.public_key.is_some()); assert!(signing_identity.public_key_fingerprint.is_some()); - assert_eq!( - store - .list_repositories(&created.workspace.workspace_id) - .unwrap() - .len(), - 1 - ); + let repositories = store + .list_repositories(&created.workspace.workspace_id) + .unwrap(); + assert_eq!(repositories.len(), 1); + assert_eq!(repositories[0].repository_key, "platform"); assert!( store .load_workspace_config(&created.workspace.workspace_id) @@ -337,6 +335,35 @@ mod tests { ); } + #[test] + fn create_rejects_blank_repository_key_without_persisting_a_workspace() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new( + crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(), + ), + ); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "blank-repository-key".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: " ".to_string(), + default_ref: None, + }, + }; + + let error = service + .create(request, owner_account(store.as_ref())) + .unwrap_err() + .to_string(); + + assert!(error.contains("invalid Repository key"), "{error}"); + assert!(store.list_workspaces().unwrap().is_empty()); + } + #[tokio::test] async fn create_recovers_same_reserved_identity_after_material_write_failure() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 6c36ba56e..1e72559f0 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -2986,7 +2986,7 @@ backend = "shared" "--display-name".to_string(), "Workspace A".to_string(), "--repository-key".to_string(), - "main".to_string(), + "platform".to_string(), "--repository".to_string(), repository.path().display().to_string(), "--default-ref".to_string(), @@ -3000,7 +3000,7 @@ backend = "shared" }; assert_eq!(options.backend_url, "http://backend.example"); assert_eq!(options.display_name, "Workspace A"); - assert_eq!(options.repository_key, "main"); + assert_eq!(options.repository_key, "platform"); assert_eq!(options.default_ref.as_deref(), Some("develop")); assert_eq!( options.repository_root, @@ -3009,6 +3009,35 @@ backend = "shared" assert!(!repository.path().join(".yoi/workspace.toml").exists()); } + #[test] + fn init_parsing_rejects_an_omitted_repository_key() { + let repository = tempfile::tempdir().unwrap(); + Command::new("git") + .args(["init", "-q"]) + .current_dir(repository.path()) + .status() + .unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + let args = vec![ + "--backend".to_string(), + "http://backend.example".to_string(), + "init".to_string(), + "--display-name".to_string(), + "Workspace A".to_string(), + "--repository".to_string(), + repository.path().display().to_string(), + ]; + + let error = parse_args_slice_with_connection_resolver(&args, &resolver) + .unwrap_err() + .to_string(); + + assert_eq!(error, "yoi init requires --repository-key KEY"); + assert!(!repository.path().join(".yoi/workspace.toml").exists()); + } + #[test] fn default_backend_target_selects_workspace_from_backend_not_repository_file() { let workspace = tempfile::tempdir().unwrap(); diff --git a/web/workspace/tests/workspace-catalog.test.ts b/web/workspace/tests/workspace-catalog.test.ts index 1839b2bef..186b4fdc5 100644 --- a/web/workspace/tests/workspace-catalog.test.ts +++ b/web/workspace/tests/workspace-catalog.test.ts @@ -1,7 +1,12 @@ declare const Deno: { test(name: string, fn: () => void | Promise): void; + readTextFile(path: URL): Promise; }; +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + function assertEquals(actual: unknown, expected: unknown): void { if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error( @@ -88,7 +93,7 @@ Deno.test("workspace creation preserves caller-owned operation key across retry" operation_key: "web-create-1", display_name: "Alpha", repository: { - repository_key: "main", + repository_key: "platform", uri: "/srv/alpha", default_ref: "develop", }, @@ -113,3 +118,27 @@ Deno.test("workspace creation preserves caller-owned operation key across retry" ); assertEquals(bodies, [request, request]); }); + +Deno.test("workspace creation form starts with an explicit required Repository key", async () => { + const source = await Deno.readTextFile( + new URL("../src/routes/+page.svelte", import.meta.url), + ); + + assert( + source.includes('let repositoryKey = $state("");'), + "the form must start without an implicit Repository key", + ); + assert( + source.includes(" Date: Fri, 2 Oct 2026 14:31:04 +0900 Subject: [PATCH 18/31] fix: stabilize persisted run transition identities --- crates/session-store/src/fs_store.rs | 28 +- crates/session-store/src/history.rs | 5 +- crates/session-store/src/public_snapshot.rs | 367 +++++++++++++++++- crates/session-store/src/segment.rs | 6 +- crates/session-store/src/segment_log.rs | 65 +++- .../session-store/src/worker_session_store.rs | 62 ++- crates/tui/src/app.rs | 1 + crates/worker/src/ipc/protocol_session.rs | 117 ++++++ crates/worker/src/segment_log_sink.rs | 1 + crates/worker/src/worker.rs | 15 +- 10 files changed, 635 insertions(+), 32 deletions(-) diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index 19a2c6d00..e5babb45c 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -211,7 +211,11 @@ impl FsStore { } } - fn parse_jsonl(content: &[u8]) -> Result, StoreError> { + fn parse_jsonl( + content: &[u8], + session_id: SessionId, + segment_id: SegmentId, + ) -> Result, StoreError> { let complete = Self::complete_jsonl_prefix(content); let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt { line: complete[..error.valid_up_to()] @@ -222,15 +226,19 @@ impl FsStore { message: error.to_string(), })?; let mut entries = Vec::new(); - for (i, line) in content.lines().enumerate() { - if line.trim().is_empty() { - continue; + let mut record_start = 0_u64; + for (index, record) in content.split_inclusive('\n').enumerate() { + let line = record.strip_suffix('\n').unwrap_or(record); + if !line.trim().is_empty() { + let mut entry: LogEntry = + serde_json::from_str(line).map_err(|error| StoreError::Corrupt { + line: index + 1, + message: error.to_string(), + })?; + entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start); + entries.push(entry); } - let entry: T = serde_json::from_str(line).map_err(|e| StoreError::Corrupt { - line: i + 1, - message: e.to_string(), - })?; - entries.push(entry); + record_start = record_start.saturating_add(record.len() as u64); } Ok(entries) } @@ -294,7 +302,7 @@ impl Store for FsStore { return Err(StoreError::NotFound(segment_id)); } let content = fs::read(&path)?; - Self::parse_jsonl(&content) + Self::parse_jsonl(&content, session_id, segment_id) } fn list_sessions(&self) -> Result, StoreError> { diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index e960bfec5..4ed2bd2bb 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize}; use crate::LoggedItem; -/// Stable logical identity of one model-visible history entry. +/// Stable logical identity of one durable public Session entry. +/// +/// Model-visible history entries and typed Run transitions share this identity +/// space so every projection can reconcile the same committed record. /// /// This value is generated at the trusted Worker session boundary and copied /// unchanged across fork, rewind, compaction retention, restore, and reboot. diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index fe3ab0e65..651e8d2c4 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -1041,15 +1041,16 @@ fn project_session_snapshot_for_segment( )), LogEntry::RunYielded { ts, + entry_id, reason, active_run_turn_count: count, } => { active_run_turn_count = Some(*count); - entries.push(legacy_entry( + entries.push(run_transition_entry( + entry_id.as_ref(), &session_key, segment_id.as_ref(), log_index, - 0, *ts, SessionSnapshotEntryData::RunYielded { reason: *reason, @@ -1059,15 +1060,16 @@ fn project_session_snapshot_for_segment( } LogEntry::RunResumed { ts, + entry_id, source, active_run_turn_count: count, } => { active_run_turn_count = Some(*count); - entries.push(legacy_entry( + entries.push(run_transition_entry( + entry_id.as_ref(), &session_key, segment_id.as_ref(), log_index, - 0, *ts, SessionSnapshotEntryData::RunResumed { source: *source, @@ -1075,29 +1077,30 @@ fn project_session_snapshot_for_segment( }, )); } - LogEntry::RunCancelled { ts } => { + LogEntry::RunCancelled { ts, entry_id } => { active_run_turn_count = None; - entries.push(legacy_entry( + entries.push(run_transition_entry( + entry_id.as_ref(), &session_key, segment_id.as_ref(), log_index, - 0, *ts, SessionSnapshotEntryData::RunCancelled, )); } LogEntry::RunErrored { ts, + entry_id, message, failure, .. } => { active_run_turn_count = None; - entries.push(legacy_entry( + entries.push(run_transition_entry( + entry_id.as_ref(), &session_key, segment_id.as_ref(), log_index, - 0, *ts, SessionSnapshotEntryData::RunError { message: message.clone(), @@ -1234,6 +1237,25 @@ fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec { .unwrap_or_default() } +fn run_transition_entry( + persisted_entry_id: Option<&crate::LoggedSessionHistoryEntryId>, + session_key: &SessionId, + segment_id: Option<&SegmentId>, + log_index: usize, + timestamp: u64, + data: SessionSnapshotEntryData, +) -> SessionSnapshotEntry { + SessionSnapshotEntry { + entry_id: persisted_entry_id + .map(|entry_id| entry_id.0.clone()) + .unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)), + timestamp, + provenance: SessionEntryProvenance::LegacyUnknown, + derived_from: Vec::new(), + data, + } +} + fn legacy_entry( session_key: &SessionId, segment_id: Option<&SegmentId>, @@ -2812,22 +2834,344 @@ mod tests { )); } + fn run_entry_ids(entries: &[SessionSnapshotEntry]) -> Vec { + entries + .iter() + .filter(|entry| { + matches!( + entry.data, + SessionSnapshotEntryData::RunYielded { .. } + | SessionSnapshotEntryData::RunResumed { .. } + | SessionSnapshotEntryData::RunCancelled + | SessionSnapshotEntryData::RunError { .. } + ) + }) + .map(|entry| entry.entry_id.clone()) + .collect() + } + + #[test] + fn saved_run_transition_ids_match_snapshot_retained_and_paged_history() { + let worker_name = "worker-run-identities"; + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let initial = vec![ + segment_start(session_id, Vec::new(), None, None), + LogEntry::AnnotatedUserInput { + ts: 2, + segments: vec![Segment::text("compact")], + history: vec![user_message(1)], + extensions: Vec::new(), + }, + ]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + segment_id, + vec![(segment_id, initial)], + ); + let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap(); + let transitions = [ + LogEntry::RunYielded { + ts: 10, + entry_id: None, + reason: protocol::RunYieldReason::Compaction, + active_run_turn_count: 2, + }, + LogEntry::RunResumed { + ts: 10, + entry_id: None, + source: protocol::RunResumeSource::Compaction, + active_run_turn_count: 2, + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "mid-run compaction failed: unavailable".into(), + failure: Some(protocol::RunFailureKind::Compaction), + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "mid-run compaction failed: unavailable".into(), + failure: Some(protocol::RunFailureKind::Compaction), + }, + LogEntry::RunCancelled { + ts: 10, + entry_id: None, + }, + ]; + for entry in transitions { + crate::segment::append_entry(&store, session_id, segment_id, entry).unwrap(); + } + + let stored = store.read_all(session_id, segment_id).unwrap(); + let stored_ids = run_entry_ids( + &project_session_snapshot_for_segment(session_id, Some(segment_id), &stored).entries, + ); + assert_eq!(stored_ids.len(), 5); + assert_eq!(stored_ids.iter().collect::>().len(), 5); + assert!( + stored_ids + .iter() + .all(|entry_id| !entry_id.starts_with("l-run-")) + ); + + let retained = read_retained_session_snapshot( + &aggregate_root, + worker_name, + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap(); + assert_eq!(run_entry_ids(&retained.snapshot.entries), stored_ids); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(page.turns.len(), 1); + assert_eq!(run_entry_ids(&page.turns[0].entries), stored_ids); + + let reconnect = read_retained_session_snapshot( + &aggregate_root, + worker_name, + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap(); + assert_eq!(run_entry_ids(&reconnect.snapshot.entries), stored_ids); + } + + #[test] + fn run_transition_ids_survive_segment_lineage_and_history_page_boundaries() { + let worker_name = "worker-run-lineage-identities"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let source = vec![ + segment_start(session_id, Vec::new(), None, None), + LogEntry::AnnotatedUserInput { + ts: 2, + segments: vec![Segment::text("source turn")], + history: vec![user_message(1)], + extensions: Vec::new(), + }, + ]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + source_segment, + vec![(source_segment, source)], + ); + let store = WorkerSessionStore::new(aggregate_root.join("session")).unwrap(); + crate::segment::append_entry( + &store, + session_id, + source_segment, + LogEntry::RunYielded { + ts: 3, + entry_id: None, + reason: protocol::RunYieldReason::Compaction, + active_run_turn_count: 1, + }, + ) + .unwrap(); + store + .append( + session_id, + source_segment, + &LogEntry::TurnEnd { + ts: 4, + turn_count: 1, + }, + ) + .unwrap(); + store + .create_segment( + session_id, + active_segment, + &[ + segment_start( + session_id, + vec![user_message(1)], + None, + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 1, + }), + ), + LogEntry::AnnotatedUserInput { + ts: 5, + segments: vec![Segment::text("active turn")], + history: vec![user_message(2)], + extensions: Vec::new(), + }, + ], + ) + .unwrap(); + for entry in [ + LogEntry::RunResumed { + ts: 6, + entry_id: None, + source: protocol::RunResumeSource::Compaction, + active_run_turn_count: 1, + }, + LogEntry::RunErrored { + ts: 7, + entry_id: None, + interrupted: false, + message: "mid-run compaction failed".into(), + failure: Some(protocol::RunFailureKind::Compaction), + }, + ] { + crate::segment::append_entry(&store, session_id, active_segment, entry).unwrap(); + } + WorkerAggregateStore::new(&aggregate_root, worker_name) + .unwrap() + .write(&crate::WorkerMetadata::new( + worker_name, + Some(crate::WorkerActiveSegmentRef::active_segment( + session_id, + active_segment, + )), + )) + .unwrap(); + + let active_ids = run_entry_ids( + &project_session_snapshot_for_segment( + session_id, + Some(active_segment), + &store.read_all(session_id, active_segment).unwrap(), + ) + .entries, + ); + let source_ids = run_entry_ids( + &project_session_snapshot_for_segment( + session_id, + Some(source_segment), + &store.read_all(session_id, source_segment).unwrap(), + ) + .entries, + ); + assert_eq!(active_ids.len(), 2); + assert_eq!(source_ids.len(), 1); + + let newest = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(run_entry_ids(&newest.turns[0].entries), active_ids); + assert!(newest.has_more); + let older = read_retained_session_history_page( + &aggregate_root, + worker_name, + newest.next_cursor.as_deref(), + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(run_entry_ids(&older.turns[0].entries), source_ids); + } + + #[test] + fn legacy_run_transition_ids_use_stable_distinct_record_positions() { + let worker_name = "worker-legacy-run-identities"; + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let log = vec![ + segment_start(session_id, Vec::new(), None, None), + LogEntry::AnnotatedUserInput { + ts: 2, + segments: vec![Segment::text("legacy")], + history: vec![user_message(1)], + extensions: Vec::new(), + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "same legacy failure".into(), + failure: None, + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "same legacy failure".into(), + failure: None, + }, + ]; + let (_root, aggregate_root) = + persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]); + let log_path = aggregate_root + .join("session") + .join("segments") + .join(format!("{segment_id}.jsonl")); + let persisted_before = std::fs::read(&log_path).unwrap(); + assert!(!String::from_utf8_lossy(&persisted_before).contains("entry_id\":\"l-run-")); + + let retained = read_retained_session_snapshot( + &aggregate_root, + worker_name, + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap(); + let snapshot_ids = run_entry_ids(&retained.snapshot.entries); + assert_eq!(snapshot_ids.len(), 2); + assert_ne!(snapshot_ids[0], snapshot_ids[1]); + assert!( + snapshot_ids + .iter() + .all(|entry_id| entry_id.starts_with("l-run-")) + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(run_entry_ids(&page.turns[0].entries), snapshot_ids); + let reread = read_retained_session_snapshot( + &aggregate_root, + worker_name, + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap(); + assert_eq!(run_entry_ids(&reread.snapshot.entries), snapshot_ids); + assert_eq!(std::fs::read(log_path).unwrap(), persisted_before); + } + #[test] fn run_transitions_project_with_timestamps_and_typed_causes() { let session_id = crate::new_session_id(); let log = vec![ LogEntry::RunYielded { ts: 10, + entry_id: None, reason: protocol::RunYieldReason::Compaction, active_run_turn_count: 2, }, LogEntry::RunResumed { ts: 20, + entry_id: None, source: protocol::RunResumeSource::Compaction, active_run_turn_count: 2, }, LogEntry::RunErrored { ts: 30, + entry_id: None, interrupted: false, message: "mid-run compaction failed: unavailable".into(), failure: Some(protocol::RunFailureKind::Compaction), @@ -2869,7 +3213,10 @@ mod tests { ts: 10, trigger: protocol::InvokeKind::UserSend, }, - LogEntry::RunCancelled { ts: 20 }, + LogEntry::RunCancelled { + ts: 20, + entry_id: None, + }, ], ); diff --git a/crates/session-store/src/segment.rs b/crates/session-store/src/segment.rs index 5275b2c6b..cbd1a12b4 100644 --- a/crates/session-store/src/segment.rs +++ b/crates/session-store/src/segment.rs @@ -347,6 +347,7 @@ pub fn save_run_yielded( segment_id, LogEntry::RunYielded { ts: segment_log::now_millis(), + entry_id: None, reason, active_run_turn_count, }, @@ -367,6 +368,7 @@ pub fn save_run_resumed( segment_id, LogEntry::RunResumed { ts: segment_log::now_millis(), + entry_id: None, source, active_run_turn_count, }, @@ -390,6 +392,7 @@ pub fn save_run_errored( segment_id, LogEntry::RunErrored { ts: segment_log::now_millis(), + entry_id: None, interrupted, message, failure: Some(RunFailureKind::Engine), @@ -568,7 +571,8 @@ pub fn append_entry( store: &impl Store, session_id: SessionId, segment_id: SegmentId, - entry: LogEntry, + mut entry: LogEntry, ) -> Result<(), StoreError> { + entry.ensure_session_entry_id(); store.append(session_id, segment_id, &entry) } diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index 736fa81ba..fb61009b0 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -140,6 +140,12 @@ pub enum LogEntry { /// preceding Invoke and a later RunResumed or terminal record. RunYielded { ts: u64, + /// Stable public identity allocated before the record is committed. + /// `None` is accepted only when reading logs written before this field + /// existed; read paths derive the same legacy identity from the durable + /// record position without rewriting the log. + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_id: Option, reason: RunYieldReason, active_run_turn_count: usize, }, @@ -149,19 +155,27 @@ pub enum LogEntry { /// It deliberately does not assert that a subsequent model call occurred. RunResumed { ts: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_id: Option, source: RunResumeSource, active_run_turn_count: usize, }, /// The logical Run ended because the user intentionally cancelled it. /// This is terminal, but is not an execution failure. - RunCancelled { ts: u64 }, + RunCancelled { + ts: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_id: Option, + }, /// `run()` / `resume()` が `EngineError` で終了した。 /// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。 /// Audit-only metadata: replay は `interrupted` のみ反映する。 RunErrored { ts: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_id: Option, interrupted: bool, message: String, /// Older records omit this field. New records distinguish failures in @@ -236,6 +250,48 @@ pub struct SegmentOrigin { pub at_turn_index: usize, } +impl LogEntry { + /// Allocate the durable public identity for a newly committed Run + /// transition. Calling this repeatedly is idempotent so the exact value + /// written to storage is also the value published to live subscribers. + pub fn ensure_session_entry_id(&mut self) { + let entry_id = match self { + Self::RunYielded { entry_id, .. } + | Self::RunResumed { entry_id, .. } + | Self::RunCancelled { entry_id, .. } + | Self::RunErrored { entry_id, .. } => entry_id, + _ => return, + }; + if entry_id.is_none() { + *entry_id = Some(crate::LoggedSessionHistoryEntryId::new()); + } + } + + /// Supply an in-memory compatibility identity for a legacy Run transition + /// that predates persisted entry IDs. The physical record position is + /// stable within the append-only Segment and distinguishes equal payloads. + /// This helper never writes the synthesized value back to the log. + pub(crate) fn ensure_legacy_session_entry_id( + &mut self, + session_id: crate::SessionId, + segment_id: crate::SegmentId, + record_start: u64, + ) { + let entry_id = match self { + Self::RunYielded { entry_id, .. } + | Self::RunResumed { entry_id, .. } + | Self::RunCancelled { entry_id, .. } + | Self::RunErrored { entry_id, .. } => entry_id, + _ => return, + }; + if entry_id.is_none() { + *entry_id = Some(crate::LoggedSessionHistoryEntryId(format!( + "l-run-{session_id}-{segment_id}-{record_start}" + ))); + } + } +} + /// State collected from log entries. #[derive(Debug, Clone)] pub struct RestoredState { @@ -878,6 +934,7 @@ mod tests { }, LogEntry::RunYielded { ts: 300, + entry_id: None, reason: RunYieldReason::Compaction, active_run_turn_count: 2, }, @@ -888,6 +945,7 @@ mod tests { }, LogEntry::RunResumed { ts: 500, + entry_id: None, source: RunResumeSource::Compaction, active_run_turn_count: 2, }, @@ -909,7 +967,10 @@ mod tests { ts: 200, turn_count: 1, }, - LogEntry::RunCancelled { ts: 300 }, + LogEntry::RunCancelled { + ts: 300, + entry_id: None, + }, ]); assert!(!state.last_run_interrupted); diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index 35218068c..a23f50bb4 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -61,6 +61,8 @@ pub(crate) struct RetainedLogRecord { pub(crate) struct RetainedSegmentReader { file: File, + session_id: SessionId, + segment_id: SegmentId, read_end: u64, buffer_start: u64, buffer: Vec, @@ -103,7 +105,12 @@ impl RetainedSegmentReader { message: "empty retained Segment log record".to_string(), }); } - let entry = serde_json::from_slice(&record)?; + let mut entry: LogEntry = serde_json::from_slice(&record)?; + entry.ensure_legacy_session_entry_id( + self.session_id, + self.segment_id, + record_start, + ); return Ok(( Some(RetainedLogRecord { start_offset: record_start, @@ -255,7 +262,10 @@ impl WorkerSessionStore { if byte_len > max_bytes { return Err(StoreError::ReadLimitExceeded); } - Ok((parse_jsonl(&bytes)?, byte_len)) + Ok(( + parse_session_log_jsonl(&bytes, session_id, segment_id)?, + byte_len, + )) } pub(crate) fn read_first_log_record_read_only_bounded( @@ -264,6 +274,7 @@ impl WorkerSessionStore { segment_id: SegmentId, max_bytes: u64, ) -> Result<(Option, u64, u64), StoreError> { + let record_start = 0; self.validate_retained_session(session_id)?; let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { @@ -289,7 +300,8 @@ impl WorkerSessionStore { message: "empty retained Segment log record".to_string(), }); } - let entry = serde_json::from_slice(&record)?; + let mut entry: LogEntry = serde_json::from_slice(&record)?; + entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start); Ok(( Some(RetainedLogRecord { start_offset: 0, @@ -308,6 +320,7 @@ impl WorkerSessionStore { start_offset: u64, max_bytes: u64, ) -> Result<(Option, u64), StoreError> { + let record_start = start_offset; self.validate_retained_session(session_id)?; let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { @@ -334,7 +347,8 @@ impl WorkerSessionStore { message: "empty retained Segment log record".to_string(), }); } - let entry = serde_json::from_slice(&record)?; + let mut entry: LogEntry = serde_json::from_slice(&record)?; + entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start); Ok(( Some(RetainedLogRecord { start_offset, @@ -353,6 +367,7 @@ impl WorkerSessionStore { end_offset: u64, max_bytes: u64, ) -> Result { + let record_start = start_offset; self.validate_retained_session(session_id)?; let record_len = end_offset @@ -387,7 +402,8 @@ impl WorkerSessionStore { }); } record.pop(); - let entry = serde_json::from_slice(&record)?; + let mut entry: LogEntry = serde_json::from_slice(&record)?; + entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start); Ok(RetainedLogRecord { start_offset, end_offset, @@ -429,6 +445,8 @@ impl WorkerSessionStore { } Ok(RetainedSegmentReader { file, + session_id, + segment_id, read_end: before_offset, buffer_start: before_offset, buffer: Vec::new(), @@ -645,7 +663,7 @@ impl Store for WorkerSessionStore { if !path.exists() { return Err(StoreError::NotFound(segment_id)); } - parse_jsonl(&fs::read(path)?) + parse_session_log_jsonl(&fs::read(path)?, session_id, segment_id) } fn list_sessions(&self) -> Result, StoreError> { @@ -1154,6 +1172,38 @@ fn complete_jsonl_prefix(content: &[u8]) -> &[u8] { .unwrap_or(&[]) } +fn parse_session_log_jsonl( + content: &[u8], + session_id: SessionId, + segment_id: SegmentId, +) -> Result, StoreError> { + let complete = complete_jsonl_prefix(content); + let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt { + line: complete[..error.valid_up_to()] + .iter() + .filter(|byte| **byte == b'\n') + .count() + + 1, + message: error.to_string(), + })?; + let mut entries = Vec::new(); + let mut record_start = 0_u64; + for (index, record) in content.split_inclusive('\n').enumerate() { + let line = record.strip_suffix('\n').unwrap_or(record); + if !line.trim().is_empty() { + let mut entry: LogEntry = + serde_json::from_str(line).map_err(|error| StoreError::Corrupt { + line: index + 1, + message: error.to_string(), + })?; + entry.ensure_legacy_session_entry_id(session_id, segment_id, record_start); + entries.push(entry); + } + record_start = record_start.saturating_add(record.len() as u64); + } + Ok(entries) +} + fn parse_jsonl(content: &[u8]) -> Result, StoreError> { let complete = complete_jsonl_prefix(content); let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c9c56e400..15aa8a62c 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3800,6 +3800,7 @@ mod completion_flow_tests { let run_errored = session_store::LogEntry::RunErrored { ts: 3, + entry_id: None, interrupted: false, message: "provider unavailable".into(), failure: None, diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 8b3af7dd8..cf83db223 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -117,11 +117,128 @@ pub async fn dispatch_worker_protocol_method( #[cfg(test)] mod tests { use super::*; + use session_store::Store; + + use crate::segment_log_sink::SegmentLogSink; + use crate::worker::{LogWriterHandle, SegmentState}; + + fn committed_entry(event: Event) -> protocol::SessionSnapshotEntry { + match event { + Event::SessionEntryCommitted { entry } => entry, + other => panic!("expected SessionEntryCommitted, got {other:?}"), + } + } + + #[test] + fn persisted_run_transitions_keep_identity_across_live_and_snapshot_projection() { + let temp = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(temp.path()).unwrap(); + let session_id = session_store::new_session_id(); + let segment_id = session_store::new_segment_id(); + let start = LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: Vec::new(), + forked_from: None, + compacted_from: None, + }; + store + .create_segment(session_id, segment_id, std::slice::from_ref(&start)) + .unwrap(); + let sink = SegmentLogSink::with_initial(vec![start]); + let writer = LogWriterHandle { + store: store.clone(), + state: SegmentState::new(session_id, segment_id, 1), + sink: sink.clone(), + in_flight: None, + }; + let (_, mut receiver) = sink.subscribe_with_snapshot(); + let transitions = [ + LogEntry::RunYielded { + ts: 10, + entry_id: None, + reason: protocol::RunYieldReason::Compaction, + active_run_turn_count: 2, + }, + LogEntry::RunResumed { + ts: 10, + entry_id: None, + source: protocol::RunResumeSource::Compaction, + active_run_turn_count: 2, + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "same compaction failure".into(), + failure: Some(protocol::RunFailureKind::Compaction), + }, + LogEntry::RunErrored { + ts: 10, + entry_id: None, + interrupted: false, + message: "same compaction failure".into(), + failure: Some(protocol::RunFailureKind::Compaction), + }, + LogEntry::RunCancelled { + ts: 10, + entry_id: None, + }, + ]; + + let mut live_ids = Vec::new(); + for transition in transitions { + writer.append_entry(transition).unwrap(); + let committed = receiver.try_recv().expect("committed transition broadcast"); + live_ids.push( + committed_entry(live_log_entry_event(committed).expect("live projection")).entry_id, + ); + } + assert_eq!( + live_ids + .iter() + .collect::>() + .len(), + 5 + ); + + let (mirror, _) = sink.subscribe_with_snapshot(); + let snapshot = session_store::public_snapshot::project_current_session_snapshot(&mirror); + let snapshot_ids: Vec<_> = snapshot + .entries + .iter() + .filter(|entry| { + matches!( + entry.data, + protocol::SessionSnapshotEntryData::RunYielded { .. } + | protocol::SessionSnapshotEntryData::RunResumed { .. } + | protocol::SessionSnapshotEntryData::RunError { .. } + | protocol::SessionSnapshotEntryData::RunCancelled + ) + }) + .map(|entry| entry.entry_id.clone()) + .collect(); + assert_eq!(snapshot_ids, live_ids); + + let persisted = store.read_all(session_id, segment_id).unwrap(); + let persisted_snapshot = + session_store::public_snapshot::project_current_session_snapshot(&persisted); + let persisted_ids: Vec<_> = persisted_snapshot + .entries + .iter() + .filter(|entry| snapshot_ids.contains(&entry.entry_id)) + .map(|entry| entry.entry_id.clone()) + .collect(); + assert_eq!(persisted_ids, live_ids); + } #[test] fn durable_run_transition_maps_to_session_entry_committed() { let event = live_log_entry_event(LogEntry::RunYielded { ts: 42, + entry_id: Some(session_store::LoggedSessionHistoryEntryId::new()), reason: protocol::RunYieldReason::Compaction, active_run_turn_count: 3, }) diff --git a/crates/worker/src/segment_log_sink.rs b/crates/worker/src/segment_log_sink.rs index 75825e73f..55da19660 100644 --- a/crates/worker/src/segment_log_sink.rs +++ b/crates/worker/src/segment_log_sink.rs @@ -330,6 +330,7 @@ mod tests { // Durable logical-Run transitions share the committed-entry lane. sink.publish(LogEntry::RunResumed { ts: now_millis(), + entry_id: None, source: protocol::RunResumeSource::Compaction, active_run_turn_count: 1, }); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index b8ad139db..f96f9e7b6 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -1426,7 +1426,8 @@ where /// Append `entry` to the log: disk write → counter bump → in-memory /// mirror push → broadcast. The Store owns physical write ordering and /// partial-write recovery; publication happens only after it returns Ok. - pub fn append_entry(&self, entry: LogEntry) -> Result<(), StoreError> { + pub fn append_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> { + entry.ensure_session_entry_id(); let _append_guard = self .state .append_lock @@ -3055,7 +3056,8 @@ impl Worker { /// Append `entry` to the session log AND publish it through the /// broadcast sink. The Store is the commit boundary: a failed write is /// never counted or published. - pub(crate) fn commit_entry(&self, entry: LogEntry) -> Result<(), StoreError> { + pub(crate) fn commit_entry(&self, mut entry: LogEntry) -> Result<(), StoreError> { + entry.ensure_session_entry_id(); let loc = self.segment_state.location(); self.store.append(loc.session_id, loc.segment_id, &entry)?; self.segment_state.increment_entries(); @@ -4707,6 +4709,7 @@ impl Worker { .expect("pause/yield resume must retain the logical-run turn budget"); self.commit_entry(LogEntry::RunResumed { ts: segment_log::now_millis(), + entry_id: None, source, active_run_turn_count, })?; @@ -5024,6 +5027,7 @@ impl Worker { // marked as already delivered to clients through the session-log sink. self.commit_entry(LogEntry::RunErrored { ts: segment_log::now_millis(), + entry_id: None, interrupted: false, message, failure: Some(RunFailureKind::Compaction), @@ -5435,6 +5439,7 @@ impl Worker { { self.commit_entry(LogEntry::RunErrored { ts: segment_log::now_millis(), + entry_id: None, interrupted: false, message: message.to_string(), failure: Some(RunFailureKind::Compaction), @@ -5458,6 +5463,7 @@ impl Worker { EngineRunExit::Yielded => { self.commit_entry(LogEntry::RunYielded { ts: segment_log::now_millis(), + entry_id: None, reason: RunYieldReason::Compaction, active_run_turn_count: active_run_turn_count .expect("yielded logical run must retain its turn budget"), @@ -5474,11 +5480,13 @@ impl Worker { EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => { self.commit_entry(LogEntry::RunCancelled { ts: segment_log::now_millis(), + entry_id: None, })?; } EngineRunExit::Interrupted(reason) => { self.commit_entry(LogEntry::RunErrored { ts: segment_log::now_millis(), + entry_id: None, interrupted, message: run_interruption_reason_message(reason), failure: Some(RunFailureKind::Engine), @@ -11442,6 +11450,7 @@ mod build_summary_prompt_tests { worker .commit_entry(LogEntry::RunErrored { ts: segment_log::now_millis(), + entry_id: None, interrupted: true, message: "provider failed".to_string(), failure: Some(RunFailureKind::Engine), @@ -11472,6 +11481,7 @@ mod build_summary_prompt_tests { worker .commit_entry(LogEntry::RunCancelled { ts: segment_log::now_millis(), + entry_id: None, }) .unwrap(); assert_eq!( @@ -11482,6 +11492,7 @@ mod build_summary_prompt_tests { worker .commit_entry(LogEntry::RunResumed { ts: segment_log::now_millis(), + entry_id: None, source: RunResumeSource::Compaction, active_run_turn_count: 1, }) From e662e2f465f6292c0118b88964d0b5c09c67a890 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 14:32:37 +0900 Subject: [PATCH 19/31] fix: refresh memory surfaces on restore --- crates/session-store/src/system_item.rs | 12 + crates/tui/src/app.rs | 1 + .../builtin/memory_surface_lifecycle.rs | 231 +++++++++++++++--- crates/worker/src/prompt/catalog.rs | 12 + crates/worker/src/worker.rs | 225 ++++++++++++++++- docs/design/subjektiv-surface.md | 4 +- resources/prompts/catalog.dcdl | 2 + .../resident_memory_restore_section.md | 13 + 8 files changed, 458 insertions(+), 42 deletions(-) create mode 100644 resources/prompts/internal/resident_memory_restore_section.md diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index cf04db4d3..4d4a28b6a 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -157,6 +157,16 @@ pub enum SystemItem { prompt_provenance: Option, }, + /// Restore-boundary refresh of the current resident Memory projection. + /// The exact rendered body is retained so replay is byte-identical; a + /// refresh with no ready summary explicitly supersedes older resident + /// context without deleting or rewriting history. + ResidentSummaryRefresh { + body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, + /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -181,6 +191,7 @@ impl SystemItem { format!("Ignored legacy procedure item: /{slug}") } SystemItem::TaskReminder { body, .. } => body.clone(), + SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -202,6 +213,7 @@ impl SystemItem { SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", + SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", SystemItem::Interrupt { .. } => "interrupt", } } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c9c56e400..26c3946ef 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2584,6 +2584,7 @@ impl App { } session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } + | session_store::SystemItem::ResidentSummaryRefresh { body, .. } | session_store::SystemItem::Interrupt { body, .. } => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index ca7c15d15..730b60c7e 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -358,42 +358,120 @@ fn bounded_editor_input( system_prompt: &str, generation: &server_api::SubjektivSurfacePrepareResponse, ) -> Result<(String, Vec), HookError> { - let mut materials = generation.materials.clone(); - loop { - let input = serde_json::to_string(&serde_json::json!({ - "question": EDITOR_QUESTION, - "body_token_budget": generation.body_token_budget, - "input_token_budget": generation.input_token_budget, - "materials": &materials, - })) - .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; - let request = Request::new() - .system(system_prompt) - .user(input.clone()) - .tool(submit_llm_tool_definition()); - let estimated_tokens = estimated_editor_request_tokens(&request)?; - if estimated_tokens <= generation.input_token_budget { - if materials.is_empty() && !generation.materials.is_empty() { - return Err(HookError::new( - HookErrorCategory::Internal, - format!( - "surface editor fixed prompt and material overhead left no grounded material within the {} token input budget", - generation.input_token_budget - ), - )); - } - return Ok((input, materials)); - } - if materials.pop().is_none() { - return Err(HookError::new( - HookErrorCategory::Internal, - format!( - "surface editor fixed prompt, JSON framing, and tool schema exceed the {} token input budget (estimated {estimated_tokens})", - generation.input_token_budget - ), - )); + let empty_tokens = editor_request_tokens_for_materials(system_prompt, generation, &[])?; + if empty_tokens > generation.input_token_budget { + return Err(HookError::new( + HookErrorCategory::Internal, + format!( + "surface editor fixed prompt, JSON framing, and tool schema exceed the {} token input budget (estimated {empty_tokens})", + generation.input_token_budget + ), + )); + } + + let mut populated_kinds = Vec::new(); + for material in &generation.materials { + if !populated_kinds.contains(&material.kind) { + populated_kinds.push(material.kind.clone()); } } + let material_token_budget = generation.input_token_budget - empty_tokens; + let per_kind_token_budget = material_token_budget / populated_kinds.len().max(1); + let mut accepted = vec![false; generation.materials.len()]; + let mut deferred = Vec::new(); + + for (index, material) in generation.materials.iter().enumerate() { + let kind_materials = generation + .materials + .iter() + .zip(&accepted) + .filter(|(selected, accepted)| **accepted && selected.kind == material.kind) + .map(|(selected, _)| selected) + .chain(std::iter::once(material)) + .collect::>(); + let all_materials = generation + .materials + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|(selected, _)| selected) + .chain(std::iter::once(material)) + .collect::>(); + let kind_cost = + editor_request_tokens_for_materials(system_prompt, generation, &kind_materials)? + .saturating_sub(empty_tokens); + let all_tokens = + editor_request_tokens_for_materials(system_prompt, generation, &all_materials)?; + if kind_cost <= per_kind_token_budget && all_tokens <= generation.input_token_budget { + accepted[index] = true; + } else { + deferred.push(index); + } + } + + // Equal per-kind shares reserve room in the final normalized request. Any + // unused share is reclaimed in the original canonical round-robin order. + for index in deferred { + let material = &generation.materials[index]; + let all_materials = generation + .materials + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|(selected, _)| selected) + .chain(std::iter::once(material)) + .collect::>(); + if editor_request_tokens_for_materials(system_prompt, generation, &all_materials)? + <= generation.input_token_budget + { + accepted[index] = true; + } + } + + let materials = generation + .materials + .iter() + .zip(accepted) + .filter_map(|(material, accepted)| accepted.then_some(material.clone())) + .collect::>(); + if materials.is_empty() && !generation.materials.is_empty() { + return Err(HookError::new( + HookErrorCategory::Internal, + format!( + "surface editor fixed prompt and material overhead left no grounded material within the {} token input budget", + generation.input_token_budget + ), + )); + } + let material_refs = materials.iter().collect::>(); + let input = serialize_editor_input(generation, &material_refs)?; + Ok((input, materials)) +} + +fn editor_request_tokens_for_materials( + system_prompt: &str, + generation: &server_api::SubjektivSurfacePrepareResponse, + materials: &[&server_api::SubjektivSurfaceMaterial], +) -> Result { + let input = serialize_editor_input(generation, materials)?; + let request = Request::new() + .system(system_prompt) + .user(input) + .tool(submit_llm_tool_definition()); + estimated_editor_request_tokens(&request) +} + +fn serialize_editor_input( + generation: &server_api::SubjektivSurfacePrepareResponse, + materials: &[&server_api::SubjektivSurfaceMaterial], +) -> Result { + serde_json::to_string(&serde_json::json!({ + "question": EDITOR_QUESTION, + "body_token_budget": generation.body_token_budget, + "input_token_budget": generation.input_token_budget, + "materials": materials, + })) + .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string())) } fn estimated_editor_request_tokens(request: &Request) -> Result { @@ -431,11 +509,19 @@ mod tests { use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; fn material(id: &str, body_bytes: usize) -> server_api::SubjektivSurfaceMaterial { + material_with_body(id, CandidateKind::Constraint, "x".repeat(body_bytes)) + } + + fn material_with_body( + id: &str, + kind: CandidateKind, + body_md: String, + ) -> server_api::SubjektivSurfaceMaterial { server_api::SubjektivSurfaceMaterial { memory_id: id.into(), revision: 1, - kind: CandidateKind::Constraint, - body_md: "x".repeat(body_bytes), + kind, + body_md, why_useful: "budget fixture".into(), staleness: None, } @@ -703,7 +789,7 @@ permission = "write" ); let (input, selected) = bounded_editor_input("system policy", &generation).unwrap(); - assert_eq!(selected.len(), 1, "deterministically trims the tail"); + assert_eq!(selected.len(), 1, "deterministically bounds one category"); assert_eq!(selected[0].memory_id, "memory-1"); assert!(input.contains(EDITOR_QUESTION)); assert!( @@ -722,6 +808,77 @@ permission = "write" ); } + #[test] + fn final_editor_budget_preserves_feasible_nonempty_categories() { + let materials = vec![ + material_with_body( + "preference-large", + CandidateKind::Preference, + "\\\"".repeat(8_000), + ), + material_with_body( + "constraint-small", + CandidateKind::Constraint, + "constraint".into(), + ), + material_with_body("decision-small", CandidateKind::Decision, "decision".into()), + material_with_body( + "assumption-small", + CandidateKind::WorkingAssumption, + "assumption".into(), + ), + material_with_body( + "question-small", + CandidateKind::OpenQuestion, + "question".into(), + ), + material_with_body("lesson-small", CandidateKind::Lesson, "lesson".into()), + ]; + let mut generation = generation(9_999, materials); + for _ in 0..3 { + let large = editor_request_tokens_for_materials( + "system policy", + &generation, + &[&generation.materials[0]], + ) + .unwrap(); + let small = editor_request_tokens_for_materials( + "system policy", + &generation, + &generation.materials[1..].iter().collect::>(), + ) + .unwrap(); + generation.input_token_budget = large.max(small); + } + let all = editor_request_tokens_for_materials( + "system policy", + &generation, + &generation.materials.iter().collect::>(), + ) + .unwrap(); + assert!(all > generation.input_token_budget); + + let (input, selected) = bounded_editor_input("system policy", &generation).unwrap(); + assert!( + !selected + .iter() + .any(|item| item.kind == CandidateKind::Preference) + ); + for kind in [ + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ] { + assert!(selected.iter().any(|item| item.kind == kind)); + } + assert!( + estimated_editor_request_tokens(&editor_request("system policy", &input)).unwrap() + <= generation.input_token_budget + ); + } + #[tokio::test] async fn actual_editor_request_is_rejected_before_provider_when_over_budget() { let calls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 685476d3a..b76ff37e5 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -104,6 +104,7 @@ pub enum WorkerPrompt { WorkingBoundariesSection, AgentsMdSection, ResidentMemorySummarySection, + ResidentMemoryRestoreSection, WorkerOrchestrationGuidanceSection, SubWorkerSpawnToolDescription, } @@ -126,6 +127,7 @@ impl WorkerPrompt { Self::WorkingBoundariesSection => "internal.working_boundaries_section", Self::AgentsMdSection => "internal.agents_md_section", Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", + Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section", Self::WorkerOrchestrationGuidanceSection => { "internal.worker_orchestration_guidance_section" } @@ -147,6 +149,7 @@ impl WorkerPrompt { WorkerPrompt::WorkingBoundariesSection, WorkerPrompt::AgentsMdSection, WorkerPrompt::ResidentMemorySummarySection, + WorkerPrompt::ResidentMemoryRestoreSection, WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::SubWorkerSpawnToolDescription, ]; @@ -502,6 +505,15 @@ impl PromptCatalog { single("summary", summary), ) } + pub fn resident_memory_restore_section( + &self, + summary: Option<&str>, + ) -> Result { + self.render( + WorkerPrompt::ResidentMemoryRestoreSection, + single("summary", summary.unwrap_or_default()), + ) + } pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index b8ad139db..9df0ff6d2 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -411,7 +411,9 @@ use crate::ipc::alerter::Alerter; use crate::ipc::interceptor::WorkerInterceptor; use crate::ipc::notify_buffer::NotifyBuffer; use crate::prompt::agents_md::read_agents_md; -use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProjection}; +use crate::prompt::catalog::{ + CatalogError, PromptCatalog, WorkerPrompt, WorkspacePromptProjection, +}; use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; @@ -2500,6 +2502,8 @@ pub struct Worker { inject_resident_summary: bool, /// Deferred resident prompt source installed by an enabled Feature. feature_resident_summary_source: Option>, + /// One-shot append-only resident refresh required at a restore boundary. + restore_resident_summary_refresh_pending: bool, /// Complete system prompt replacement installed by an enabled Feature. feature_system_prompt_override: Option, /// Typed user submissions in submit order. K-th entry corresponds to @@ -2753,6 +2757,7 @@ impl Worker { prompts, inject_resident_summary: true, feature_resident_summary_source: None, + restore_resident_summary_refresh_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), sink: SegmentLogSink::new(), @@ -3819,6 +3824,62 @@ impl Worker { } } + async fn ensure_restore_resident_summary_refreshed(&mut self) -> Result<(), WorkerError> + where + St: Clone + 'static, + { + if !self.restore_resident_summary_refresh_pending { + return Ok(()); + } + let Some(source) = self.feature_resident_summary_source.clone() else { + self.restore_resident_summary_refresh_pending = false; + return Ok(()); + }; + let summary = if self.inject_resident_summary { + source.load().await + } else { + None + }; + let prompt_catalog = self.prompts.load_full(); + let body = prompt_catalog.resident_memory_restore_section(summary.as_deref())?; + let prompt_provenance = + Some(self.prompt_render_provenance(WorkerPrompt::ResidentMemoryRestoreSection.key())); + let item = SystemItem::ResidentSummaryRefresh { + body: body.clone(), + prompt_provenance, + }; + let metadata = new_history_metadata( + WorkerHistoryProvenance::BackendInstruction { operation_id: None }, + None, + ); + self.commit_entry(LogEntry::AnnotatedSystemItem { + ts: segment_log::now_millis(), + entry: session_store::LoggedSystemHistoryEntry { + item, + metadata: metadata.clone(), + }, + extensions: Vec::new(), + })?; + let history_entry = HistoryEntry::new(Item::system_message(body), metadata); + let mut annotate = history_annotator( + self.log_writer_handle(), + vec![history_entry.clone()], + self.pending_committed_history.clone(), + ); + let (engine, session) = ( + self.engine.as_mut().expect("worker present"), + &mut self.session, + ); + engine.append_history_with( + session.history_mut(), + std::iter::once(history_entry.item), + &mut annotate, + )?; + session.note_mutation(); + self.restore_resident_summary_refresh_pending = false; + Ok(()) + } + /// Render the manifest-supplied instruction template exactly once, /// just before the first LLM turn, append the fixed trailing /// section (scope summary + optional AGENTS.md), and hand the @@ -3826,9 +3887,12 @@ impl Worker { /// Subsequent invocations are no-ops: the template field is /// consumed with `Option::take()`, so the materialised value /// persists across all later turns and compaction. - async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> { + async fn ensure_system_prompt_materialized(&mut self) -> Result<(), WorkerError> + where + St: Clone + 'static, + { let Some(template) = self.system_prompt_template.take() else { - return Ok(()); + return self.ensure_restore_resident_summary_refreshed().await; }; if let Some(rendered) = self.feature_system_prompt_override.take() { self.engine @@ -6549,6 +6613,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + restore_resident_summary_refresh_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), sink: SegmentLogSink::new(), @@ -6638,6 +6703,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + restore_resident_summary_refresh_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), sink: SegmentLogSink::new(), @@ -6762,6 +6828,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + restore_resident_summary_refresh_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), sink: SegmentLogSink::new(), @@ -7143,6 +7210,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + restore_resident_summary_refresh_pending: true, feature_system_prompt_override: None, user_segments: state.user_segments, // Seed the mirror with the entries we just replayed so a @@ -11075,6 +11143,157 @@ mod build_summary_prompt_tests { assert!(worker.history().is_empty()); } + #[tokio::test] + async fn restored_worker_appends_latest_resident_surface_without_rewriting_history() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("workspace"); + std::fs::create_dir_all(&cwd).unwrap(); + let runtime = dir.path().join("runtime"); + std::fs::create_dir_all(&runtime).unwrap(); + let _runtime = + crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(&runtime); + let store = session_store::CombinedStore::new( + session_store::FsStore::new(dir.path().join("sessions")).unwrap(), + session_store::FsWorkerStore::new(dir.path().join("workers")).unwrap(), + ); + let manifest = WorkerManifest::from_toml(&format!( + r#" +[worker] +name = "restore-resident-{}" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] +instruction = "default" + +[[scope.allow]] +target = "{}" +permission = "write" +"#, + uuid::Uuid::now_v7(), + cwd.display() + )) + .unwrap(); + let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); + let scope = Scope::writable(&cwd).unwrap(); + let mut original = Worker::new( + manifest.clone(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store.clone(), + WorkerWorkspaceContext::no_workspace(), + authority.clone(), + scope, + ) + .await + .unwrap(); + original.set_system_prompt_template( + SystemPromptTemplate::parse( + "default", + crate::prompt::source::PromptCatalogSource::builtins_only(), + ) + .unwrap(), + ); + let (old_source, _) = + test_system_prompt_contribution_source(Some("old subject surface".into())); + original.install_system_prompt_contribution(Some(old_source), None); + original.materialize_durable_session_head().await.unwrap(); + let session_id = original.session_id(); + let segment_id = original.segment_id(); + assert!( + original + .engine() + .get_system_prompt() + .unwrap() + .contains("old subject surface") + ); + drop(original); + + let mut restored = + Worker::, _>::restore_from_manifest_with_context_and_model_client( + session_id, + segment_id, + manifest.clone(), + store.clone(), + crate::prompt::source::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::no_workspace(), + authority.clone(), + Some(Box::new(NoopClient)), + ) + .await + .unwrap(); + let history_before = restored.history().to_vec(); + let (current_source, current_loads) = + test_system_prompt_contribution_source(Some("latest subject surface".into())); + restored.install_system_prompt_contribution(Some(current_source), None); + restored.materialize_durable_session_head().await.unwrap(); + + assert_eq!(current_loads.load(Ordering::SeqCst), 1); + assert_eq!(&restored.history()[..history_before.len()], history_before); + let refresh = restored + .history() + .last() + .unwrap() + .as_text() + .unwrap() + .to_string(); + assert!(refresh.contains("latest subject surface")); + assert!(refresh.contains("supersedes every resident memory summary")); + assert!(!refresh.contains("old subject surface")); + let refreshed_len = restored.history().len(); + restored.materialize_durable_session_head().await.unwrap(); + assert_eq!(restored.history().len(), refreshed_len); + drop(restored); + + let mut stale_restore = + Worker::, _>::restore_from_manifest_with_context_and_model_client( + session_id, + segment_id, + manifest, + store.clone(), + crate::prompt::source::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::no_workspace(), + authority, + Some(Box::new(NoopClient)), + ) + .await + .unwrap(); + let stale_history_before = stale_restore.history().to_vec(); + let (stale_source, stale_loads) = test_system_prompt_contribution_source(None); + stale_restore.install_system_prompt_contribution(Some(stale_source), None); + stale_restore + .materialize_durable_session_head() + .await + .unwrap(); + + assert_eq!(stale_loads.load(Ordering::SeqCst), 1); + assert_eq!( + &stale_restore.history()[..stale_history_before.len()], + stale_history_before + ); + let tombstone = stale_restore + .history() + .last() + .unwrap() + .as_text() + .unwrap() + .to_string(); + assert!(tombstone.contains("No current ready resident memory surface")); + assert!(!tombstone.contains("latest subject surface")); + let entries = store.read_all(session_id, segment_id).unwrap(); + assert!(matches!( + entries.last(), + Some(LogEntry::AnnotatedSystemItem { + entry: session_store::LoggedSystemHistoryEntry { + item: SystemItem::ResidentSummaryRefresh { .. }, + .. + }, + .. + }) + )); + } + #[tokio::test] async fn memory_consolidation_prompt_uses_bound_workspace_language() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index 457e2841c..ffabf0638 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -14,7 +14,7 @@ Selection is deterministic: 4. records are allocated round-robin by category, up to 24 records total; 5. the 10,000-token material budget is initially divided equally across categories as a fairness reservation; unused shares are then reclaimed in the same canonical round-robin order, so one large category cannot exclude another non-empty category or strand usable capacity; 6. both category and aggregate material bounds use the provider-independent estimate `ceil(UTF-8 bytes / 4)`; -7. before invoking the editor, the Worker measures the actual normalized Agen request fields containing the rendered system prompt, conversation items (including the fixed question and material JSON), and the typed `SubmitMemorySurface` tool definition/schema against the 12,000-token complete-input budget. It deterministically removes tail materials until the initial request fits, fails rather than publishing a false empty surface if none fit, and guards every subsequent correction request before it reaches the provider. Provider-specific HTTP envelope bytes are outside this normalized request budget. +7. before invoking the editor, the Worker measures the actual normalized Agen request fields containing the rendered system prompt, conversation items (including the fixed question and material JSON), and the typed `SubmitMemorySurface` tool definition/schema against the 12,000-token complete-input budget. It reapplies equal per-category fairness reservations under that final estimator, reclaims unused shares in canonical order, fails rather than publishing a false empty surface if no grounded material fits, and guards every subsequent correction request before it reaches the provider. Provider-specific HTTP envelope bytes are outside this normalized request budget. The clean-context editor uses the effective `builtin:subjektiv-memory-consolidation` profile model (`codex-oauth/gpt-5.6-luna`, medium reasoning). A generation permits three model turns (the initial attempt plus at most two correction turns). A store-generation conflict causes at most one complete re-read and regeneration. The published Markdown is capped at 1,024 tokens by the same estimate. @@ -22,7 +22,7 @@ Every editor point carries one or more exact `{memory_id, revision}` references. Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. -Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Worker prompt materialization remains a start/restore boundary and does not rewrite recorded history. +Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. New Workers materialize the current surface in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface or an explicit no-current-surface tombstone; subsequent turns in the same restored process do not append it again. ## Semantic fixture and verification diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index 13d8fc12c..d38cfda1a 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -33,6 +33,7 @@ internalInterruptSystemNote = import "./internal/interrupt_system_note.md"; internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md"; internalAgentsMdSection = import "./internal/agents_md_section.md"; internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; +internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md"; internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; in @@ -72,6 +73,7 @@ in working_boundaries_section = internalWorkingBoundariesSection.content; agents_md_section = internalAgentsMdSection.content; resident_memory_summary_section = internalResidentMemorySummarySection.content; + resident_memory_restore_section = internalResidentMemoryRestoreSection.content; worker_orchestration_guidance_section = commonWorkerOrchestration.content; sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; }; diff --git a/resources/prompts/internal/resident_memory_restore_section.md b/resources/prompts/internal/resident_memory_restore_section.md new file mode 100644 index 000000000..307c20c7b --- /dev/null +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -0,0 +1,13 @@ + +--- +## Resident memory refresh + +This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. + +{% if summary %} +### Current ready surface + +{{ summary }} +{% else %} +No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. +{% endif %} From 7b13303724691611d243c67793466cfe1c72fd81 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 14:34:51 +0900 Subject: [PATCH 20/31] fix: preserve repository key for backend validation --- crates/tui/src/backend_workspace_picker.rs | 24 +++++++++++- .../workspace-server/src/workspace_catalog.rs | 39 +++++++++++-------- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index af917b668..b4b95755a 100644 --- a/crates/tui/src/backend_workspace_picker.rs +++ b/crates/tui/src/backend_workspace_picker.rs @@ -195,12 +195,14 @@ fn prompt_create_request_with_io( let Some(display_name) = prompt_line(input, output, "Workspace display name: ")? else { return Ok(None); }; + let display_name = display_name.trim().to_string(); if display_name.is_empty() { return Ok(None); } let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else { return Ok(None); }; + let uri = uri.trim().to_string(); if uri.is_empty() { writeln!(output, "Repository path/URI is required.")?; return Ok(None); @@ -210,7 +212,7 @@ fn prompt_create_request_with_io( else { return Ok(None); }; - if repository_key.is_empty() { + if repository_key.trim().is_empty() { writeln!(output, "Repository key is required.")?; continue; } @@ -220,6 +222,7 @@ fn prompt_create_request_with_io( else { return Ok(None); }; + let default_ref = default_ref.trim().to_string(); let operation_key = format!( "tui-workspace-create-{}-{}", std::process::id(), @@ -250,7 +253,13 @@ fn prompt_line( if input.read_line(&mut value)? == 0 { return Ok(None); } - Ok(Some(value.trim().to_string())) + if value.ends_with('\n') { + value.pop(); + if value.ends_with('\r') { + value.pop(); + } + } + Ok(Some(value)) } #[cfg(test)] @@ -300,6 +309,17 @@ mod tests { assert_eq!(output.matches("Repository key (required): ").count(), 3); } + #[test] + fn workspace_creation_does_not_normalize_a_nonblank_repository_key() { + let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n"); + + assert_eq!( + request.unwrap().repository.repository_key, + " platform ", + "the Backend must validate the creator's exact Repository key" + ); + } + #[test] fn workspace_creation_accepts_explicit_main_repository_key() { let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n"); diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index ac98cae2e..d9fce280e 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -336,7 +336,7 @@ mod tests { } #[test] - fn create_rejects_blank_repository_key_without_persisting_a_workspace() { + fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let service = WorkspaceCatalogService::new( store.clone(), @@ -345,23 +345,30 @@ mod tests { ), ); let repository = git_repository(); - let request = WorkspaceCreateRequest { - operation_key: "blank-repository-key".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository.path().display().to_string(), - repository_key: " ".to_string(), - default_ref: None, - }, - }; + let owner_account_id = owner_account(store.as_ref()); - let error = service - .create(request, owner_account(store.as_ref())) - .unwrap_err() - .to_string(); + for (operation_key, repository_key) in [ + ("blank-repository-key", " "), + ("spaced-repository-key", " platform "), + ] { + let request = WorkspaceCreateRequest { + operation_key: operation_key.to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: repository_key.to_string(), + default_ref: None, + }, + }; - assert!(error.contains("invalid Repository key"), "{error}"); - assert!(store.list_workspaces().unwrap().is_empty()); + let error = service + .create(request, owner_account_id.clone()) + .unwrap_err() + .to_string(); + + assert!(error.contains("invalid Repository key"), "{error}"); + assert!(store.list_workspaces().unwrap().is_empty()); + } } #[tokio::test] From dfe837655a6d2e8c0cfd5b234b956925f2b30d20 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 15:01:09 +0900 Subject: [PATCH 21/31] fix: preserve resident surface startup states --- crates/memory/src/backend.rs | 18 ++++ crates/worker-runtime/src/worker_backend.rs | 4 - crates/worker/src/controller.rs | 35 ++++++++ crates/worker/src/feature/builtin/memory.rs | 50 +++++------ crates/worker/src/prompt/catalog.rs | 6 +- crates/worker/src/worker.rs | 74 ++++++++++++++-- crates/workspace-server/src/memory_backend.rs | 19 +++-- crates/workspace-server/src/server.rs | 85 +++++++++++++++---- docs/design/subjektiv-surface.md | 2 +- .../resident_memory_restore_section.md | 6 +- 10 files changed, 230 insertions(+), 69 deletions(-) diff --git a/crates/memory/src/backend.rs b/crates/memory/src/backend.rs index 8b7b8a546..c8186bf97 100644 --- a/crates/memory/src/backend.rs +++ b/crates/memory/src/backend.rs @@ -41,6 +41,7 @@ pub enum MemoryBackendHttpResponse { #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryBackendOperationResult { ToolOutput(MemoryToolOutput), + ResidentSummary(MemoryResidentSummaryOutput), Acknowledged(MemoryBackendAckOutput), StagingWritten(MemoryStagingWriteOutput), } @@ -52,6 +53,23 @@ pub struct MemoryToolOutput { pub content: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MemoryResidentSummaryAvailability { + Ready, + Ungenerated, + Stale, + Failed, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MemoryResidentSummaryOutput { + pub availability: MemoryResidentSummaryAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryQueryOperation { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index c1ffeca2d..c4bf58844 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1266,10 +1266,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { } } - worker - .materialize_durable_session_head() - .await - .map_err(|error| format!("materialize durable Worker session head: {error}"))?; let workspace_client = worker.workspace_client_handle(); let started = prepared.start().await.map_err(|error| match error { WorkerBootstrapError::Worker(source) => { diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 0597a926d..4a0d99bbe 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -767,6 +767,17 @@ impl WorkerController { // the actual registered set instead of a hand-maintained mirror. worker.engine().tool_server_handle().flush_pending(); + // Runtime-owned fresh Workers persist their initial model-visible head + // before controller exposure. Feature installation above must happen first + // so the resident contribution is loaded into that durable prompt. Restored + // Workers retain their established first-turn refresh lifecycle. + if runtime_managed && worker.needs_initial_session_head_materialization() { + worker + .materialize_durable_session_head() + .await + .map_err(|error| std::io::Error::other(error.to_string()))?; + } + // === 4. Initial runtime files + WorkerSharedState + WorkerHandle + // SocketServer === let manifest_toml = toml::to_string_pretty(worker.manifest()).unwrap_or_default(); @@ -3465,6 +3476,30 @@ mod tests { use tempfile::TempDir; use tokio::net::UnixListener; + #[test] + fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() { + let production = include_str!("controller.rs") + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("controller test module marker"); + let startup = production + .split_once("async fn spawn_initialized") + .map(|(_, startup)| startup) + .expect("controller startup implementation"); + let feature_install = startup + .find("let fs_for_view = register_worker_tools(") + .expect("feature installation call"); + let materialize = startup + .find(".materialize_durable_session_head()") + .expect("runtime session-head materialization"); + let exposure = startup + .find("let handle = WorkerHandle") + .expect("controller handle exposure"); + + assert!(feature_install < materialize); + assert!(materialize < exposure); + } + #[test] fn automatic_memory_extraction_destinations_are_mutually_exclusive() { let mut memory = manifest::MemoryFeatureProfileConfig::default(); diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 3aa2f50db..85f8936da 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -23,8 +23,8 @@ use crate::feature::{ ToolDeclaration, }; use crate::worker::{ - SystemPromptContributionSource, WorkspaceClient, WorkspaceClientError, WorkspaceRequest, - WorkspaceRequestMethod, + SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient, + WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; #[derive(Clone, Debug)] @@ -652,7 +652,7 @@ struct WorkspaceResidentSummarySource { #[async_trait] impl SystemPromptContributionSource for WorkspaceResidentSummarySource { - async fn load(&self) -> Option { + async fn load(&self) -> SystemPromptContribution { match self .client .execute_memory_backend_operation( @@ -662,14 +662,22 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { ) .await { - Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => output.content, + Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) + if output.availability + == memory::backend::MemoryResidentSummaryAvailability::Ready => + { + SystemPromptContribution::Ready(output.content.unwrap_or_default()) + } + Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(_)) => { + SystemPromptContribution::Unavailable + } Ok(other) => { tracing::debug!(?other, "unexpected resident Memory Backend result"); - None + SystemPromptContribution::Unavailable } Err(error) => { tracing::debug!(%error, "resident Memory summary unavailable"); - None + SystemPromptContribution::Unavailable } } } @@ -843,8 +851,8 @@ mod tests { let body = serde_json::json!({ "status": "ok", "result": { - "kind": "tool_output", - "summary": "resident Memory summary collected", + "kind": "resident_summary", + "availability": "ready", "content": content, } }) @@ -976,12 +984,8 @@ mod tests { .unwrap() .unwrap(); assert_eq!( - plan.resident_summary_source - .unwrap() - .load() - .await - .as_deref(), - Some("# Durable Memory") + plan.resident_summary_source.unwrap().load().await, + SystemPromptContribution::Ready("# Durable Memory".into()) ); } @@ -1016,22 +1020,12 @@ mod tests { .unwrap(); assert_eq!( - first - .resident_summary_source - .unwrap() - .load() - .await - .as_deref(), - Some("first resident summary") + first.resident_summary_source.unwrap().load().await, + SystemPromptContribution::Ready("first resident summary".into()) ); assert_eq!( - restored - .resident_summary_source - .unwrap() - .load() - .await - .as_deref(), - Some("updated resident summary") + restored.resident_summary_source.unwrap().load().await, + SystemPromptContribution::Ready("updated resident summary".into()) ); } diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index b76ff37e5..f8c52cc47 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -507,11 +507,15 @@ impl PromptCatalog { } pub fn resident_memory_restore_section( &self, + surface_ready: bool, summary: Option<&str>, ) -> Result { self.render( WorkerPrompt::ResidentMemoryRestoreSection, - single("summary", summary.unwrap_or_default()), + minijinja::context! { + surface_ready => surface_ready, + summary => summary.unwrap_or_default(), + }, ) } pub fn worker_orchestration_guidance_section(&self) -> Result { diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 9df0ff6d2..3b24407a5 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2336,9 +2336,15 @@ impl WorkerSession { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SystemPromptContribution { + Ready(String), + Unavailable, +} + #[async_trait::async_trait] pub(crate) trait SystemPromptContributionSource: Send + Sync { - async fn load(&self) -> Option; + async fn load(&self) -> SystemPromptContribution; } #[derive(Debug, Clone)] @@ -3835,13 +3841,17 @@ impl Worker { self.restore_resident_summary_refresh_pending = false; return Ok(()); }; - let summary = if self.inject_resident_summary { + let contribution = if self.inject_resident_summary { source.load().await } else { - None + SystemPromptContribution::Unavailable + }; + let (surface_ready, summary) = match &contribution { + SystemPromptContribution::Ready(summary) => (true, Some(summary.as_str())), + SystemPromptContribution::Unavailable => (false, None), }; let prompt_catalog = self.prompts.load_full(); - let body = prompt_catalog.resident_memory_restore_section(summary.as_deref())?; + let body = prompt_catalog.resident_memory_restore_section(surface_ready, summary)?; let prompt_provenance = Some(self.prompt_render_provenance(WorkerPrompt::ResidentMemoryRestoreSection.key())); let item = SystemItem::ResidentSummaryRefresh { @@ -3925,7 +3935,14 @@ impl Worker { } let resident_summary = if self.inject_resident_summary { match &self.feature_resident_summary_source { - Some(source) => source.load().await, + Some(source) => match source.load().await { + SystemPromptContribution::Ready(summary) if !summary.is_empty() => { + Some(summary) + } + SystemPromptContribution::Ready(_) | SystemPromptContribution::Unavailable => { + None + } + }, None => None, } } else { @@ -3981,6 +3998,10 @@ impl Worker { .is_some_and(|state| state.pre_run_eligible(self.total_tokens().tokens)) } + pub(crate) fn needs_initial_session_head_materialization(&self) -> bool { + self.system_prompt_template.is_some() + } + /// Materialize the initial durable session head without running the model. /// /// Runtime-managed Workers call this before creation is accepted so even a @@ -8770,13 +8791,13 @@ mod build_summary_prompt_tests { use super::*; struct TestSystemPromptContributionSource { - value: Option, + value: SystemPromptContribution, load_count: Arc, } #[async_trait::async_trait] impl SystemPromptContributionSource for TestSystemPromptContributionSource { - async fn load(&self) -> Option { + async fn load(&self) -> SystemPromptContribution { self.load_count.fetch_add(1, Ordering::SeqCst); self.value.clone() } @@ -8788,7 +8809,10 @@ mod build_summary_prompt_tests { let load_count = Arc::new(AtomicUsize::new(0)); ( Arc::new(TestSystemPromptContributionSource { - value, + value: value.map_or( + SystemPromptContribution::Unavailable, + SystemPromptContribution::Ready, + ), load_count: Arc::clone(&load_count), }), load_count, @@ -11246,6 +11270,40 @@ permission = "write" assert_eq!(restored.history().len(), refreshed_len); drop(restored); + let mut empty_restore = + Worker::, _>::restore_from_manifest_with_context_and_model_client( + session_id, + segment_id, + manifest.clone(), + store.clone(), + crate::prompt::source::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::no_workspace(), + authority.clone(), + Some(Box::new(NoopClient)), + ) + .await + .unwrap(); + let (empty_source, empty_loads) = + test_system_prompt_contribution_source(Some(String::new())); + empty_restore.install_system_prompt_contribution(Some(empty_source), None); + empty_restore + .materialize_durable_session_head() + .await + .unwrap(); + assert_eq!(empty_loads.load(Ordering::SeqCst), 1); + let empty_boundary = empty_restore + .history() + .last() + .unwrap() + .as_text() + .unwrap() + .to_string(); + assert!( + empty_boundary.contains("current ready resident memory surface is intentionally empty") + ); + assert!(!empty_boundary.contains("No current ready resident memory surface")); + drop(empty_restore); + let mut stale_restore = Worker::, _>::restore_from_manifest_with_context_and_model_client( session_id, diff --git a/crates/workspace-server/src/memory_backend.rs b/crates/workspace-server/src/memory_backend.rs index a242c099f..394755b6a 100644 --- a/crates/workspace-server/src/memory_backend.rs +++ b/crates/workspace-server/src/memory_backend.rs @@ -1,8 +1,9 @@ use memory::backend::{ MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult, MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation, - MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation, - MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput, + MemoryResidentSummaryAvailability, MemoryResidentSummaryOutput, MemoryStagingCloseAction, + MemoryStagingCloseOperation, MemoryStagingListOperation, MemoryStagingReadOperation, + MemoryStagingWriteOutput, MemoryToolOutput, }; use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord}; use memory::schema::{SourceEvidenceRef, SourceRef}; @@ -32,7 +33,7 @@ pub fn execute_memory_backend_operation_with_authority( .map(MemoryBackendOperationResult::ToolOutput) } MemoryBackendOperation::ResidentSummary(_operation) => Ok( - MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?), + MemoryBackendOperationResult::ResidentSummary(execute_resident_summary(authority)?), ), MemoryBackendOperation::AppendAudit(_operation) => Ok( MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput { @@ -88,16 +89,18 @@ pub fn execute_memory_backend_operation_with_authority( } } -fn execute_resident_summary(authority: &A) -> Result { +fn execute_resident_summary( + authority: &A, +) -> Result { let document = authority.ensure_memory_document()?; if document.body_md.trim().is_empty() { - Ok(MemoryToolOutput { - summary: "resident memory summary unavailable".to_string(), + Ok(MemoryResidentSummaryOutput { + availability: MemoryResidentSummaryAvailability::Unavailable, content: None, }) } else { - Ok(MemoryToolOutput { - summary: "resident memory document collected".to_string(), + Ok(MemoryResidentSummaryOutput { + availability: MemoryResidentSummaryAvailability::Ready, content: Some(document.body_md), }) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6fea2c5e5..3314c3798 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -21,7 +21,7 @@ use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, - MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryToolOutput, + MemoryConsolidateStagingOperation, MemoryConsolidationOutput, }; use protocol::Segment; use protocol::stream::{decode_method, encode_event}; @@ -16590,34 +16590,39 @@ fn execute_scoped_resident_summary( .map_err(|error| Error::Store(error.to_string()))? .resident_surface(subject_id) .map_err(|error| Error::Store(error.to_string()))?; - let (summary, content) = match resident.availability { + Ok(MemoryBackendOperationResult::ResidentSummary( + resident_summary_output(resident), + )) +} + +fn resident_summary_output( + resident: crate::subjektiv::ResidentSurface, +) -> memory::backend::MemoryResidentSummaryOutput { + let (availability, content) = match resident.availability { crate::subjektiv::SurfaceAvailability::Ready => { let snapshot = resident.snapshot.expect("ready surface has a snapshot"); - if snapshot.body_md.is_empty() { - ("subject resident Memory surface is normally empty", None) - } else { - ( - "subject resident Memory surface collected", - Some(snapshot.body_md), - ) - } - } - crate::subjektiv::SurfaceAvailability::Ungenerated => { - ("subject resident Memory surface is not generated", None) + ( + memory::backend::MemoryResidentSummaryAvailability::Ready, + (!snapshot.body_md.is_empty()).then_some(snapshot.body_md), + ) } + crate::subjektiv::SurfaceAvailability::Ungenerated => ( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + ), crate::subjektiv::SurfaceAvailability::Stale => ( - "subject resident Memory surface is stale and was omitted", + memory::backend::MemoryResidentSummaryAvailability::Stale, None, ), crate::subjektiv::SurfaceAvailability::Failed => ( - "subject resident Memory surface generation failed and was omitted", + memory::backend::MemoryResidentSummaryAvailability::Failed, None, ), }; - Ok(MemoryBackendOperationResult::ToolOutput(MemoryToolOutput { - summary: summary.into(), + memory::backend::MemoryResidentSummaryOutput { + availability, content, - })) + } } const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; @@ -30146,6 +30151,50 @@ mod tests { bytes } + #[test] + fn resident_summary_transport_preserves_ready_empty_state() { + let output = resident_summary_output(crate::subjektiv::ResidentSurface { + availability: crate::subjektiv::SurfaceAvailability::Ready, + snapshot: Some(crate::subjektiv::SurfaceSnapshot { + schema_version: 1, + id: "surface-empty".into(), + subject_id: "subject-a".into(), + body_md: String::new(), + memory_refs: Vec::new(), + built_from_store_revision: 7, + created_at: "2026-10-02T00:00:00Z".into(), + }), + }); + + assert_eq!( + output.availability, + memory::backend::MemoryResidentSummaryAvailability::Ready + ); + assert_eq!(output.content, None); + + for (surface, expected) in [ + ( + crate::subjektiv::SurfaceAvailability::Ungenerated, + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + ), + ( + crate::subjektiv::SurfaceAvailability::Stale, + memory::backend::MemoryResidentSummaryAvailability::Stale, + ), + ( + crate::subjektiv::SurfaceAvailability::Failed, + memory::backend::MemoryResidentSummaryAvailability::Failed, + ), + ] { + let output = resident_summary_output(crate::subjektiv::ResidentSurface { + availability: surface, + snapshot: None, + }); + assert_eq!(output.availability, expected); + assert_eq!(output.content, None); + } + } + #[test] fn external_permission_levels_map_to_existing_bits_and_reject_legacy_partial_grants() { let bits = |capabilities| { diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index ffabf0638..c76050af8 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -22,7 +22,7 @@ Every editor point carries one or more exact `{memory_id, revision}` references. Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. -Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. New Workers materialize the current surface in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface or an explicit no-current-surface tombstone; subsequent turns in the same restored process do not append it again. +Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. The resident Backend contract transports typed `ready`, `un-generated`, `failed`, and `stale` availability separately from optional Markdown, so a valid ready-empty snapshot cannot be conflated with omission or request failure. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Runtime-owned new Workers install Features before durably materializing the initial session head, ensuring the current contribution is present in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface, an explicit ready-empty state, or a no-current-surface tombstone; subsequent turns in the same restored process do not append it again. ## Semantic fixture and verification diff --git a/resources/prompts/internal/resident_memory_restore_section.md b/resources/prompts/internal/resident_memory_restore_section.md index 307c20c7b..fc8a22907 100644 --- a/resources/prompts/internal/resident_memory_restore_section.md +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -4,10 +4,14 @@ This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. -{% if summary %} +{% if surface_ready %} ### Current ready surface +{% if summary %} {{ summary }} {% else %} +The current ready resident memory surface is intentionally empty. +{% endif %} +{% else %} No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. {% endif %} From cf3cf4b7661957830d265d97afc313a46fd9d996 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 15:28:48 +0900 Subject: [PATCH 22/31] feat: expose worker model and context metadata --- crates/protocol/src/lib.rs | 90 ++++++++++++++++-- crates/protocol/src/typescript.rs | 87 ++++++++++++++---- crates/tui/src/app.rs | 89 ++++++++++++++++-- crates/tui/src/console/mod.rs | 6 ++ crates/worker-runtime/src/http_server.rs | 2 + crates/worker-runtime/src/runtime.rs | 5 + crates/worker-runtime/src/worker_backend.rs | 2 + crates/worker/src/controller.rs | 78 +++++++++++++++- crates/worker/src/discovery.rs | 12 +++ crates/worker/src/internal_worker.rs | 28 +++++- crates/worker/src/runtime/dir.rs | 2 + crates/worker/src/shared_state.rs | 43 ++++++++- crates/worker/src/spawn/comm_tools.rs | 2 + crates/worker/src/spawn/registry.rs | 1 + .../src/lib/generated/protocol-validator.ts | 4 +- web/workspace/src/lib/generated/protocol.ts | 39 ++++++-- .../WorkerContextStatus.browser.spec.ts | 42 +++++++++ .../console/WorkerContextStatus.svelte | 69 ++++++++++++++ .../workspace/console/WorkerRunStatus.svelte | 2 +- .../src/lib/workspace/console/model.test.ts | 91 +++++++++++++++++++ .../src/lib/workspace/console/model.ts | 82 ++++++++++++++++- .../lib/workspace/console/run-status.test.ts | 56 ++++++++++++ .../console/worker-console.ui.test.ts | 3 +- .../lib/workspace/console/worker-metadata.ts | 43 +++++++++ .../workers/[workerRef]/console/+page.svelte | 13 ++- 25 files changed, 834 insertions(+), 57 deletions(-) create mode 100644 web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte create mode 100644 web/workspace/src/lib/workspace/console/worker-metadata.ts diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7bfe7213c..19f09a62f 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot { pub worker: InternalWorkerRef, pub revision: u64, pub session: SessionSnapshot, + /// Public execution metadata for this child. Older snapshots and service + /// workers may omit it; clients must never substitute the parent's values. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub greeting: Option, #[serde(default)] pub status: WorkerStatus, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1667,11 +1671,45 @@ impl InFlightToolCallState { } } -/// Worker self-description rendered by the TUI when a session starts empty. +/// Public reasoning configuration resolved for the model client. /// -/// Built once in the Worker controller from the resolved manifest and -/// transmitted alongside `Event::Snapshot` so clients don't need -/// their own view of the manifest. +/// This deliberately exposes only the effective provider control, not the +/// Worker manifest or provider credentials. The tagged shape prevents token +/// budgets from being presented as effort labels. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReasoningConfig { + Effort { effort: String }, + BudgetTokens { budget_tokens: i32 }, +} + +/// Provenance of the current-context token count in a [`Greeting`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ContextTokenSource { + /// Provider-reported input occupancy from the most recent LLM request. + Measured, + /// Current history projected from the Worker's token accounting records. + Estimated, +} + +/// Current prompt-context occupancy, distinct from cumulative run traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub struct ContextUsage { + pub tokens: u64, + pub source: ContextTokenSource, +} + +/// Worker self-description rendered by clients when a session starts empty. +/// +/// Snapshot metadata contains only resolved public execution information so +/// clients do not need their own copy of the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -1680,14 +1718,22 @@ pub struct Greeting { pub cwd: String, pub provider: String, pub model: String, + /// Effective reasoning control. Absent means unspecified or unavailable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, pub scope_summary: String, pub tools: Vec, - /// Model context window in tokens. Always filled by the Worker greeting. + /// Resolved effective model context window in tokens. Legacy producers use + /// zero when this is unavailable; clients must treat that as unknown. #[serde(default)] pub context_window: u64, - /// Estimated current session context tokens at connect time. + /// Legacy numeric mirror retained for mixed-version clients. #[serde(default)] pub context_tokens: u64, + /// Typed current-context value. Older snapshots omit it; clients may fall + /// back to `context_tokens` only when `context_window` is known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_usage: Option, } // --------------------------------------------------------------------------- @@ -2354,6 +2400,11 @@ mod tests { tools: vec!["Read".into()], context_window: 200_000, context_tokens: 42_000, + reasoning: Some(ReasoningConfig::BudgetTokens { budget_tokens: -1 }), + context_usage: Some(ContextUsage { + tokens: 42_000, + source: ContextTokenSource::Measured, + }), }, state: WorkerStatus::Paused.into(), in_flight: InFlightSnapshot::default(), @@ -2372,6 +2423,19 @@ mod tests { assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000); + assert_eq!( + parsed["data"]["greeting"]["reasoning"]["kind"], + "budget_tokens" + ); + assert_eq!(parsed["data"]["greeting"]["reasoning"]["budget_tokens"], -1); + assert_eq!( + parsed["data"]["greeting"]["context_usage"]["tokens"], + 42_000 + ); + assert_eq!( + parsed["data"]["greeting"]["context_usage"]["source"], + "measured" + ); assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy"); assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused"); } @@ -2381,7 +2445,17 @@ mod tests { let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#; let decoded: Event = serde_json::from_str(inbound).unwrap(); match decoded { - Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), + Event::Snapshot { + greeting, + in_flight, + .. + } => { + assert!(in_flight.is_empty()); + assert_eq!(greeting.reasoning, None); + assert_eq!(greeting.context_usage, None); + assert_eq!(greeting.context_window, 0); + assert_eq!(greeting.context_tokens, 0); + } other => panic!("expected Snapshot, got {other:?}"), } @@ -2399,6 +2473,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Running.into(), in_flight: InFlightSnapshot { diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 5535bb557..c759b0992 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -6,17 +6,18 @@ use ts_rs::{Config, TS}; use crate::{ Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase, - CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, - InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, - InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, - PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary, - PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, - RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment, - SessionContentPart, SessionConversationTurn, SessionEntryProvenance, - SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, - SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, - SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, - WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, + CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage, + ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot, + InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, + InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType, + PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, + ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, + RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart, + SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary, + SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry, + SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy, + ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, + WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, subscription::{ @@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); - push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -256,20 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { )); let pending = SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { - subscription_id, + subscription_id: subscription_id.clone(), payload: SubscriptionEventPayload::WorkerProtocol { - worker_id, + worker_id: worker_id.clone(), event: Event::PendingSubmissionsChanged { pending: PendingSubmissionsSnapshot::default(), }, }, })); - for fixture in [&subscribed, &pending] { + let greeting = Greeting { + worker_name: "fixture-worker".into(), + cwd: "/fixture".into(), + provider: "openai_responses".into(), + model: "gpt-6-astra".into(), + reasoning: Some(ReasoningConfig::Effort { + effort: "high".into(), + }), + scope_summary: "fixture scope".into(), + tools: vec!["Read".into()], + context_window: 272_000, + context_tokens: 142_000, + context_usage: Some(ContextUsage { + tokens: 142_000, + source: ContextTokenSource::Measured, + }), + }; + let snapshot = + SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { + subscription_id, + payload: SubscriptionEventPayload::WorkerProtocol { + worker_id, + event: Event::Snapshot { + session: SessionSnapshot { + pending_submissions: PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }, + greeting: greeting.clone(), + state: WorkerStateSnapshot::initial(), + in_flight: InFlightSnapshot::default(), + internal_workers: vec![InternalWorkerSnapshot { + worker: InternalWorkerRef { + session_id: "fixture-child-session".into(), + name: "fixture-child".into(), + parent_session_id: Some("fixture-parent-session".into()), + kind: InternalWorkerKind::SubWorker, + }, + revision: 1, + session: SessionSnapshot { + pending_submissions: PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }, + greeting: Some(greeting), + status: WorkerStatus::Idle, + error: None, + in_flight: InFlightSnapshot::default(), + internal_workers: Vec::new(), + }], + }, + }, + })); + for fixture in [&subscribed, &pending, &snapshot] { fixture .validate() .expect("Rust-serialized Browser compatibility fixture must validate"); } - vec![subscribed, pending] + vec![subscribed, pending, snapshot] } fn close_object_shapes(schema: &mut Value) { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 9c7052dc6..b2b475f41 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1403,7 +1403,9 @@ impl App { output_tokens, cache_read_input_tokens, } => { - self.session_context_tokens = input_tokens.unwrap_or(0); + if let Some(input_tokens) = input_tokens { + self.session_context_tokens = input_tokens; + } // Subtract the cache-hit portion so a tool loop that // re-sends the same prefix on every request doesn't // re-count it. cache_creation stays in (it is full @@ -1475,7 +1477,6 @@ impl App { return None; } self.active_compaction = None; - self.session_context_tokens = 0; let new_segment_id = lifecycle .new_segment_id .as_deref() @@ -2337,8 +2338,12 @@ impl App { ) -> InternalWorkerView { let mut app = App::new(snapshot.worker.name.clone()); app.mode = mode; - app.restore_session(&snapshot.session, None); - app.apply_in_flight_snapshot(snapshot.in_flight); + if let Some(greeting) = snapshot.greeting { + app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight); + } else { + app.restore_session(&snapshot.session, None); + app.apply_in_flight_snapshot(snapshot.in_flight); + } app.set_worker_status(snapshot.status); if let Some(error) = snapshot.error { let _ = app.handle_worker_event(Event::Error { @@ -2424,7 +2429,10 @@ impl App { ) { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; - self.session_context_tokens = greeting.context_tokens; + self.session_context_tokens = greeting + .context_usage + .map(|usage| usage.tokens) + .unwrap_or(greeting.context_tokens); self.restore_session(session, Some(greeting)); self.apply_in_flight_snapshot(in_flight); } @@ -2441,10 +2449,15 @@ impl App { _ => None, }) }); + let greeting = greeting.map(|mut greeting| { + greeting.context_tokens = 0; + greeting.context_usage = None; + greeting + }); if let Some(greeting) = greeting.clone() { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; - self.session_context_tokens = greeting.context_tokens; + self.session_context_tokens = 0; } let missing_greeting = greeting.is_none(); self.restore_session(session, greeting); @@ -2836,6 +2849,8 @@ mod rewind_refresh_tests { assert!(!blocks_contain(&app, "old post-target output")); assert_eq!(composer_text(&app), "selected rewind input"); assert!(blocks_contain(&app, "Rewound session")); + assert_eq!(app.session_context_tokens, 0); + assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None); } #[test] @@ -2946,6 +2961,8 @@ mod rewind_refresh_tests { tools: vec![], context_window: 100, context_tokens: 10, + reasoning: None, + context_usage: None, } } @@ -4021,6 +4038,7 @@ mod completion_flow_tests { }, revision, status: WorkerStatus::Idle, + greeting: None, session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), @@ -4031,6 +4049,28 @@ mod completion_flow_tests { } } + #[test] + fn internal_worker_snapshot_uses_child_greeting_metadata() { + let mut app = App::new("parent".into()); + let mut child = test_internal_worker_snapshot("child", "research", 1); + let mut greeting = test_greeting(); + greeting.worker_name = "research".into(); + greeting.model = "child-model".into(); + greeting.context_window = 64_000; + greeting.context_usage = Some(protocol::ContextUsage { + tokens: 12_000, + source: protocol::ContextTokenSource::Measured, + }); + child.greeting = Some(greeting); + + app.replace_internal_worker_snapshots(vec![child]); + + let child_app = &app.internal_workers[0].app; + assert_eq!(child_app.greeting.as_ref().unwrap().model, "child-model"); + assert_eq!(child_app.context_window, 64_000); + assert_eq!(child_app.session_context_tokens, 12_000); + } + #[test] fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() { let mut app = App::new("parent".into()); @@ -4309,6 +4349,7 @@ mod completion_flow_tests { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), }, + greeting: None, status: WorkerStatus::Running, error: None, in_flight: Default::default(), @@ -4505,15 +4546,21 @@ mod completion_flow_tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, } } #[test] - fn snapshot_initializes_context_usage() { + fn snapshot_prefers_typed_context_usage_over_legacy_mirror() { let mut app = App::new("test".into()); let mut greeting = test_greeting(); greeting.context_window = 123_000; - greeting.context_tokens = 45_000; + greeting.context_tokens = 1; + greeting.context_usage = Some(protocol::ContextUsage { + tokens: 45_000, + source: protocol::ContextTokenSource::Measured, + }); app.handle_worker_event(Event::Snapshot { session: protocol::SessionSnapshot { @@ -4530,6 +4577,28 @@ mod completion_flow_tests { assert_eq!(app.session_context_tokens, 45_000); } + #[test] + fn snapshot_falls_back_to_legacy_context_tokens() { + let mut app = App::new("test".into()); + let mut greeting = test_greeting(); + greeting.context_window = 100_000; + greeting.context_tokens = 25_000; + + app.handle_worker_event(Event::Snapshot { + session: protocol::SessionSnapshot { + pending_submissions: protocol::PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }, + greeting, + state: test_worker_state(WorkerStatus::Idle), + in_flight: Default::default(), + internal_workers: Vec::new(), + }); + + assert_eq!(app.context_window, 100_000); + assert_eq!(app.session_context_tokens, 25_000); + } + #[test] fn usage_updates_session_context_tokens_without_cache_discount() { let mut app = App::new("test".into()); @@ -4566,7 +4635,7 @@ mod completion_flow_tests { } #[test] - fn compact_done_resets_session_context_tokens() { + fn legacy_compact_done_does_not_fabricate_zero_context_usage() { let mut app = App::new("test".into()); app.session_context_tokens = 42_000; @@ -4574,7 +4643,7 @@ mod completion_flow_tests { lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string()); app.handle_worker_event(Event::CompactDone { lifecycle }); - assert_eq!(app.session_context_tokens, 0); + assert_eq!(app.session_context_tokens, 42_000); } #[test] diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index bcac99c32..cdaba24c1 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -499,10 +499,12 @@ impl RetainedSnapshotSocket { cwd: String::new(), provider: "retained session".to_string(), model: String::new(), + reasoning: None, scope_summary: "read-only retained session".to_string(), tools: Vec::new(), context_window: 0, context_tokens: 0, + context_usage: None, }, state: protocol::WorkerStateSnapshot { last_command_id: 0, @@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture( cwd: workspace_root.display().to_string(), provider: "e2e-fixture".to_string(), model: "canned".to_string(), + reasoning: None, scope_summary: "isolated e2e rewind fixture".to_string(), tools: Vec::new(), context_window: 0, context_tokens: 0, + context_usage: None, }, }); @@ -2784,10 +2788,12 @@ mod tests { cwd: "/tmp".into(), provider: "test".into(), model: "test".into(), + reasoning: None, scope_summary: "".into(), tools: vec![], context_window: 0, context_tokens: 0, + context_usage: None, } } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e3b5ca2c3..04a116fb2 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -3992,6 +3992,8 @@ mod ws_tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: protocol::WorkerStateSnapshot::initial(), in_flight: protocol::InFlightSnapshot { diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index c80787cf3..a2a9d922d 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -5474,6 +5474,7 @@ mod tests { worker, revision, status, + greeting: None, session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), @@ -5499,6 +5500,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: protocol::WorkerStatus::Idle.into(), in_flight: protocol::InFlightSnapshot::default(), @@ -8111,6 +8114,8 @@ mod tests { tools: Vec::new(), context_window: 128, context_tokens: 64, + reasoning: None, + context_usage: None, }, state: protocol::WorkerStatus::Running.into(), in_flight: protocol::InFlightSnapshot { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index c4bf58844..20eeed10d 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -4520,6 +4520,8 @@ mod tests { tools: Vec::new(), context_window: 1_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, )); hub.workers.lock().unwrap().insert( diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 4a0d99bbe..8a12f6951 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -151,7 +151,7 @@ impl WorkerHandle { .snapshot(); let event = Event::Snapshot { session, - greeting: self.shared_state.greeting.clone(), + greeting: self.shared_state.greeting(), state: self.shared_state.snapshot(), in_flight, internal_workers: self.spawned_registry.internal_worker_snapshots(), @@ -303,6 +303,10 @@ async fn finish_controller_run( // the terminal run boundary so reconnect snapshots cannot append stale // partial text/tool arguments after newer entries. worker.clear_in_flight_events(); + shared_state.update_context_usage( + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await; } @@ -792,6 +796,11 @@ impl WorkerController { manifest_toml.clone(), greeting, )); + let usage_state = shared_state.clone(); + worker.engine_mut().on_usage(move |event| { + usage_state + .update_context_usage(event.input_tokens, protocol::ContextTokenSource::Measured); + }); if let Some(fs_for_view) = fs_for_view { shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); } @@ -2509,6 +2518,10 @@ async fn controller_loop( } } }; + shared_state.update_context_usage( + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); if !matches!( result, Err(WorkerError::Store(_)) @@ -2576,6 +2589,10 @@ async fn controller_loop( .await { worker.clear_in_flight_events(); + shared_state.update_context_usage( + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); let snapshot = shared_state.transition(WorkerState::Idle); let _ = working_event_tx.send(Event::WorkerState { snapshot }); } @@ -3383,7 +3400,24 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool { }) } -fn build_greeting(worker: &Worker) -> protocol::Greeting +fn public_reasoning_config( + reasoning: &agen::llm_client::capability::ReasoningControl, +) -> protocol::ReasoningConfig { + match reasoning { + agen::llm_client::capability::ReasoningControl::Effort(effort) => { + protocol::ReasoningConfig::Effort { + effort: effort.as_str().to_owned(), + } + } + agen::llm_client::capability::ReasoningControl::BudgetTokens(budget_tokens) => { + protocol::ReasoningConfig::BudgetTokens { + budget_tokens: *budget_tokens, + } + } + } +} + +pub(crate) fn build_greeting(worker: &Worker) -> protocol::Greeting where C: LlmClient + 'static, St: Store, @@ -3426,6 +3460,7 @@ where .into_iter() .map(|def| def.name) .collect(); + let context_tokens = worker.total_tokens().tokens; protocol::Greeting { worker_name: manifest.worker.name.clone(), cwd: worker @@ -3434,10 +3469,19 @@ where .unwrap_or_default(), provider: provider_name, model: model_id, + reasoning: manifest + .engine + .reasoning + .as_ref() + .map(public_reasoning_config), scope_summary: worker.scope_snapshot().summary(), tools: tool_names, context_window, - context_tokens: worker.total_tokens().tokens, + context_tokens, + context_usage: Some(protocol::ContextUsage { + tokens: context_tokens, + source: protocol::ContextTokenSource::Estimated, + }), } } @@ -3500,6 +3544,28 @@ mod tests { assert!(materialize < exposure); } + #[test] + fn public_reasoning_config_preserves_effort_and_signed_budget() { + let effort = + public_reasoning_config(&agen::llm_client::capability::ReasoningControl::Effort( + agen::llm_client::capability::ReasoningEffort::Other("provider-native".into()), + )); + assert_eq!( + effort, + protocol::ReasoningConfig::Effort { + effort: "provider-native".into(), + } + ); + + let budget = public_reasoning_config( + &agen::llm_client::capability::ReasoningControl::BudgetTokens(-1), + ); + assert_eq!( + budget, + protocol::ReasoningConfig::BudgetTokens { budget_tokens: -1 } + ); + } + #[test] fn automatic_memory_extraction_destinations_are_mutually_exclusive() { let mut memory = manifest::MemoryFeatureProfileConfig::default(); @@ -3658,6 +3724,8 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, )); let notify_buffer = NotifyBuffer::new(); @@ -3707,6 +3775,8 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), @@ -4415,6 +4485,8 @@ mod tests { tools: Vec::new(), context_window: 1, context_tokens: 0, + reasoning: None, + context_usage: None, }, ); assert!( diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs index df0ec473e..e27fbb20d 100644 --- a/crates/worker/src/discovery.rs +++ b/crates/worker/src/discovery.rs @@ -1507,6 +1507,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), @@ -1543,6 +1545,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), @@ -1634,6 +1638,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), @@ -1661,6 +1667,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), @@ -1765,6 +1773,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Paused.into(), in_flight: Default::default(), @@ -1820,6 +1830,8 @@ mod tests { tools: Vec::new(), context_window: 0, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 21ff16e03..0266f77c0 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -7,7 +7,7 @@ //! need durable domain audit must keep using their domain authority (for example Memory audit). use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use agen::timeline::event::UsageEvent; use agen::{Engine, EngineError, llm_client::LlmClient}; @@ -17,7 +17,9 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr use tokio::sync::broadcast; use uuid::Uuid; -use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events}; +use crate::controller::{ + build_greeting, wire_event_bridges_on_engine, wire_workdir_command_events, +}; use crate::feature::FeatureRegistryBuilder; use crate::in_flight::{InFlightEvents, snapshot_from_guard}; use crate::ipc::alerter::Alerter; @@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand { #[derive(Debug, Clone)] pub(crate) struct InternalWorkerSessionSnapshot { pub session: protocol::SessionSnapshot, + pub greeting: Option, pub status: WorkerStatus, pub error: Option, pub in_flight: InFlightSnapshot, @@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle { visibility: InternalWorkerVisibility, last_error: Arc>>, last_outcome: Arc>>, + greeting: Option>>, child_registry: Option>, sink: SegmentLogSink, #[cfg(test)] @@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle { }; InternalWorkerSessionSnapshot { session: session_store::public_snapshot::project_current_session_snapshot(&entries), + greeting: self.greeting.as_ref().map(|greeting| { + greeting + .read() + .expect("internal Worker greeting lock poisoned") + .clone() + }), status: match self.status() { InternalWorkerSessionStatus::Running => WorkerStatus::Running, InternalWorkerSessionStatus::Paused => WorkerStatus::Paused, @@ -851,7 +861,19 @@ pub(crate) async fn prepare_internal_worker_session( worker.attach_alerter(alerter.clone()); worker.attach_working_event_tx(event_tx.clone()); worker.attach_in_flight_events(in_flight.clone()); + let greeting = Arc::new(RwLock::new(build_greeting(&worker))); wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight); + let usage_greeting = greeting.clone(); + worker.engine_mut().on_usage(move |event| { + let mut greeting = usage_greeting + .write() + .expect("internal Worker greeting lock poisoned"); + greeting.context_tokens = event.input_tokens.unwrap_or_default(); + greeting.context_usage = event.input_tokens.map(|tokens| protocol::ContextUsage { + tokens, + source: protocol::ContextTokenSource::Measured, + }); + }); let session_id = worker.session_id(); let segment_id = worker.segment_id(); @@ -874,6 +896,7 @@ pub(crate) async fn prepare_internal_worker_session( visibility, last_error: last_error.clone(), last_outcome: last_outcome.clone(), + greeting: Some(greeting), child_registry, sink, #[cfg(test)] @@ -1206,6 +1229,7 @@ pub(crate) fn test_internal_worker_session( visibility, last_error: Arc::new(Mutex::new(None)), last_outcome: Arc::new(Mutex::new(None)), + greeting: None, child_registry: None, sink, fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/crates/worker/src/runtime/dir.rs b/crates/worker/src/runtime/dir.rs index e812ffe6f..ec09099d4 100644 --- a/crates/worker/src/runtime/dir.rs +++ b/crates/worker/src/runtime/dir.rs @@ -212,6 +212,8 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, ) } diff --git a/crates/worker/src/shared_state.rs b/crates/worker/src/shared_state.rs index 73fe014e0..8da06e535 100644 --- a/crates/worker/src/shared_state.rs +++ b/crates/worker/src/shared_state.rs @@ -39,7 +39,7 @@ pub struct WorkerSharedState { pub worker_name: String, pub segment_id: SegmentId, pub manifest_toml: String, - pub greeting: protocol::Greeting, + greeting: RwLock, state: RwLock, accepted_commands: RwLock>, /// Worker-from-the-inside view of the filesystem. Set once in @@ -62,7 +62,7 @@ impl WorkerSharedState { worker_name, segment_id, manifest_toml, - greeting, + greeting: RwLock::new(greeting), state: RwLock::new(WorkerStateSnapshot::initial()), accepted_commands: RwLock::new(VecDeque::new()), fs_view: OnceLock::new(), @@ -90,6 +90,22 @@ impl WorkerSharedState { self.flow_transition_enabled.load(Ordering::Acquire) } + pub fn greeting(&self) -> protocol::Greeting { + self.greeting + .read() + .expect("worker greeting lock poisoned") + .clone() + } + + pub fn update_context_usage(&self, tokens: Option, source: protocol::ContextTokenSource) { + let mut greeting = self + .greeting + .write() + .expect("worker greeting lock poisoned"); + greeting.context_tokens = tokens.unwrap_or_default(); + greeting.context_usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source }); + } + pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot { let mut snapshot = self .state @@ -245,9 +261,32 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, } } + #[test] + fn current_context_usage_is_reflected_in_reconnect_greeting() { + let state = test_state(); + state.update_context_usage(Some(42_000), protocol::ContextTokenSource::Measured); + + let greeting = state.greeting(); + assert_eq!(greeting.context_tokens, 42_000); + assert_eq!( + greeting.context_usage, + Some(protocol::ContextUsage { + tokens: 42_000, + source: protocol::ContextTokenSource::Measured, + }) + ); + + state.update_context_usage(None, protocol::ContextTokenSource::Measured); + let greeting = state.greeting(); + assert_eq!(greeting.context_tokens, 0); + assert_eq!(greeting.context_usage, None); + } + #[test] fn initial_snapshot_is_idle() { let state = test_state(); diff --git a/crates/worker/src/spawn/comm_tools.rs b/crates/worker/src/spawn/comm_tools.rs index 6b73f25a2..14f4ab3fe 100644 --- a/crates/worker/src/spawn/comm_tools.rs +++ b/crates/worker/src/spawn/comm_tools.rs @@ -97,6 +97,8 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, state: WorkerStatus::Idle.into(), in_flight: Default::default(), diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index f0b05eeec..bfd280e47 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -995,6 +995,7 @@ fn internal_worker_snapshot( worker, revision, session: snapshot.session, + greeting: snapshot.greeting, status: snapshot.status, error: snapshot.error, in_flight: snapshot.in_flight, diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index e2ab28acc..0a1eff3c9 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,9 +21,9 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = - [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; + [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"revision":1,"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); type JsonObject = Record; diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 7063b7214..7212a00fb 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -200,21 +200,42 @@ export type SubmissionDisposition = "started" | "queued"; export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array, }; +export type ReasoningConfig = { "kind": "effort", effort: string, } | { "kind": "budget_tokens", budget_tokens: number, }; + +export type ContextTokenSource = "measured" | "estimated"; + +export type ContextUsage = { tokens: number, source: ContextTokenSource, }; + +export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, +/** + * Effective reasoning control. Absent means unspecified or unavailable. + */ +reasoning?: ReasoningConfig | null, scope_summary: string, tools: Array, +/** + * Resolved effective model context window in tokens. Legacy producers use + * zero when this is unavailable; clients must treat that as unknown. + */ +context_window: number, +/** + * Legacy numeric mirror retained for mixed-version clients. + */ +context_tokens: number, +/** + * Typed current-context value. Older snapshots omit it; clients may fall + * back to `context_tokens` only when `context_window` is known. + */ +context_usage?: ContextUsage | null, }; + export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } }; export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, }; -export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array, }; - -export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array, +export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, /** - * Model context window in tokens. Always filled by the Worker greeting. + * Public execution metadata for this child. Older snapshots and service + * workers may omit it; clients must never substitute the parent's values. */ -context_window: number, -/** - * Estimated current session context tokens at connect time. - */ -context_tokens: number, }; +greeting?: Greeting | null, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array, }; export type Alert = { level: AlertLevel, source: AlertSource, message: string, /** diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts new file mode 100644 index 000000000..56003bf51 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import WorkerContextStatus from "./WorkerContextStatus.svelte"; + +afterEach(cleanup); + +test("renders resolved model, reasoning, and measured context accessibly", () => { + const view = render(WorkerContextStatus, { + metadata: { + model: "gpt-6-astra", + reasoning: { kind: "effort", effort: "high" }, + contextWindow: 272_000, + contextTokens: 142_000, + contextSource: "measured", + }, + }); + + const status = view.getByLabelText("Worker model and context"); + expect(status.textContent).toContain("gpt-6-astra · high"); + expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)"); +}); + +test("renders honest unavailable state instead of a fabricated zero percent", async () => { + const view = render(WorkerContextStatus, { metadata: null }); + expect(view.getByText("Model unavailable")).not.toBeNull(); + expect(view.getByText("Context unavailable")).not.toBeNull(); + expect(view.container.textContent).not.toContain("0%"); + + await view.rerender({ + metadata: { + model: "claude", + reasoning: { kind: "budget_tokens", budget_tokens: 8_192 }, + contextWindow: 64_000, + contextTokens: 12_000, + contextSource: "estimated", + }, + }); + expect(view.container.textContent).toContain("claude · 8.2k token budget"); + expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)"); +}); diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte new file mode 100644 index 000000000..248cf5025 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte @@ -0,0 +1,69 @@ + + +
+ {modelSummary} + + {contextSummary} +
+ + diff --git a/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte index 70c5e9117..2cfe428d1 100644 --- a/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte +++ b/web/workspace/src/lib/workspace/console/WorkerRunStatus.svelte @@ -60,7 +60,7 @@ {requests} {requestLabel} - ↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)} + Run ↑{formatRunTokens(uploadTokens)}/↓{formatRunTokens(outputTokens)} From 3d37f056ad88a586860a4fe2866859596ef2f503 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 16:04:55 +0900 Subject: [PATCH 23/31] fix: synchronize live worker context metadata --- crates/protocol/src/lib.rs | 34 +++++ crates/tui/src/app.rs | 66 +++++++- crates/tui/src/ui.rs | 34 ++++- crates/worker/src/controller.rs | 73 ++++++++- crates/worker/src/internal_worker.rs | 141 ++++++++++++------ .../src/lib/generated/protocol-validator.ts | 2 +- web/workspace/src/lib/generated/protocol.ts | 2 +- .../WorkerContextStatus.browser.spec.ts | 11 ++ .../src/lib/workspace/console/model.test.ts | 21 ++- .../src/lib/workspace/console/model.ts | 21 +++ .../lib/workspace/console/run-status.test.ts | 4 + .../lib/workspace/console/worker-metadata.ts | 11 +- 12 files changed, 352 insertions(+), 68 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 19f09a62f..2d8e8c6e9 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1245,6 +1245,16 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] cache_read_input_tokens: Option, }, + /// Authoritative current-context occupancy after a non-request boundary. + /// + /// Unlike [`Event::Usage`], this is not cumulative request traffic. It lets + /// connected clients converge with reconnect snapshots after run completion, + /// compaction, rewind, and other history rewrites. `None` explicitly means + /// the current value is unavailable and stale occupancy must be cleared. + ContextUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, + }, RunEnd { result: RunResult, }, @@ -2440,6 +2450,30 @@ mod tests { assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused"); } + #[test] + fn context_usage_event_roundtrips_typed_value_and_unknown() { + let event = Event::ContextUsage { + usage: Some(ContextUsage { + tokens: 25_000, + source: ContextTokenSource::Estimated, + }), + }; + let json = serde_json::to_string(&event).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::json!({ + "event": "context_usage", + "data": { + "usage": { "tokens": 25_000, "source": "estimated" } + } + }) + ); + assert!(matches!( + serde_json::from_str::(r#"{"event":"context_usage","data":{}}"#).unwrap(), + Event::ContextUsage { usage: None } + )); + } + #[test] fn event_snapshot_in_flight_roundtrip_and_default() { let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#; diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index b2b475f41..0f45861d9 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -247,6 +247,9 @@ pub struct App { /// Latest session context tokens reported by the Worker. This is the raw /// `input_tokens` value and is independent from per-run upload totals. pub session_context_tokens: u64, + /// Availability and provenance for `session_context_tokens`. `None` means + /// the current value is unknown and clients must not render a fabricated 0%. + pub session_context_source: Option, pub context_window: u64, pub turn_index: usize, pub current_tool: Option, @@ -352,6 +355,7 @@ impl App { run_upload_tokens: 0, run_output_tokens: 0, session_context_tokens: 0, + session_context_source: None, context_window: 0, turn_index: 0, current_tool: None, @@ -1405,6 +1409,9 @@ impl App { } => { if let Some(input_tokens) = input_tokens { self.session_context_tokens = input_tokens; + self.session_context_source = Some(protocol::ContextTokenSource::Measured); + } else { + self.session_context_source = None; } // Subtract the cache-hit portion so a tool loop that // re-sends the same prefix on every request doesn't @@ -1416,6 +1423,14 @@ impl App { self.run_upload_tokens += net_input; self.run_output_tokens += output_tokens.unwrap_or(0); } + Event::ContextUsage { usage } => { + if let Some(usage) = usage { + self.session_context_tokens = usage.tokens; + self.session_context_source = Some(usage.source); + } else { + self.session_context_source = None; + } + } Event::Error { code, message } => { self.handle_error(code, message); } @@ -2429,10 +2444,14 @@ impl App { ) { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; - self.session_context_tokens = greeting - .context_usage - .map(|usage| usage.tokens) - .unwrap_or(greeting.context_tokens); + let context_usage = greeting.context_usage.or_else(|| { + (greeting.context_window > 0).then_some(protocol::ContextUsage { + tokens: greeting.context_tokens, + source: protocol::ContextTokenSource::Estimated, + }) + }); + self.session_context_tokens = context_usage.map(|usage| usage.tokens).unwrap_or_default(); + self.session_context_source = context_usage.map(|usage| usage.source); self.restore_session(session, Some(greeting)); self.apply_in_flight_snapshot(in_flight); } @@ -2458,6 +2477,7 @@ impl App { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; self.session_context_tokens = 0; + self.session_context_source = None; } let missing_greeting = greeting.is_none(); self.restore_session(session, greeting); @@ -2850,6 +2870,7 @@ mod rewind_refresh_tests { assert_eq!(composer_text(&app), "selected rewind input"); assert!(blocks_contain(&app, "Rewound session")); assert_eq!(app.session_context_tokens, 0); + assert_eq!(app.session_context_source, None); assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None); } @@ -4575,6 +4596,10 @@ mod completion_flow_tests { assert_eq!(app.context_window, 123_000); assert_eq!(app.session_context_tokens, 45_000); + assert_eq!( + app.session_context_source, + Some(protocol::ContextTokenSource::Measured) + ); } #[test] @@ -4597,6 +4622,10 @@ mod completion_flow_tests { assert_eq!(app.context_window, 100_000); assert_eq!(app.session_context_tokens, 25_000); + assert_eq!( + app.session_context_source, + Some(protocol::ContextTokenSource::Estimated) + ); } #[test] @@ -4610,10 +4639,39 @@ mod completion_flow_tests { }); assert_eq!(app.session_context_tokens, 42_000); + assert_eq!( + app.session_context_source, + Some(protocol::ContextTokenSource::Measured) + ); assert_eq!(app.run_upload_tokens, 2_000); assert_eq!(app.run_output_tokens, 9); } + #[test] + fn context_usage_event_converges_live_state_without_changing_run_traffic() { + let mut app = App::new("test".into()); + app.run_upload_tokens = 2_000; + app.run_output_tokens = 9; + + app.handle_worker_event(Event::ContextUsage { + usage: Some(protocol::ContextUsage { + tokens: 45_000, + source: protocol::ContextTokenSource::Estimated, + }), + }); + + assert_eq!(app.session_context_tokens, 45_000); + assert_eq!( + app.session_context_source, + Some(protocol::ContextTokenSource::Estimated) + ); + assert_eq!(app.run_upload_tokens, 2_000); + assert_eq!(app.run_output_tokens, 9); + + app.handle_worker_event(Event::ContextUsage { usage: None }); + assert_eq!(app.session_context_source, None); + } + #[test] fn memory_worker_event_updates_actionbar_state() { let mut app = App::new("test".into()); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5c6fe83e6..e7f453b57 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1830,13 +1830,20 @@ fn draw_separator(frame: &mut Frame, area: Rect) { } fn context_usage_text(app: &App) -> String { - let pct = if app.context_window == 0 { - 0 - } else { - ((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64 + let Some(source) = app.session_context_source else { + return "Context unavailable".to_string(); }; + if app.context_window == 0 { + return "Context unavailable".to_string(); + } + let pct = + ((app.session_context_tokens as f64 / app.context_window as f64) * 100.0).round() as u64; + let estimate = matches!(source, protocol::ContextTokenSource::Estimated) + .then_some("~") + .unwrap_or_default(); format!( - "{} / {} ({}%)", + "{}{} / {} ({}%)", + estimate, fmt_tokens(app.session_context_tokens), fmt_tokens(app.context_window), pct @@ -2194,6 +2201,23 @@ mod tests { ); } + #[test] + fn context_usage_distinguishes_unknown_measured_and_estimated_values() { + let mut app = App::new("worker".into()); + app.context_window = 100_000; + app.session_context_tokens = 0; + app.session_context_source = None; + assert_eq!(context_usage_text(&app), "Context unavailable"); + + app.session_context_tokens = 25_000; + app.session_context_source = Some(protocol::ContextTokenSource::Measured); + assert_eq!(context_usage_text(&app), "25.0k / 100.0k (25%)"); + + app.session_context_tokens = 20_000; + app.session_context_source = Some(protocol::ContextTokenSource::Estimated); + assert_eq!(context_usage_text(&app), "~20.0k / 100.0k (20%)"); + } + #[test] fn run_elapsed_uses_console_style_units() { assert_eq!(fmt_run_elapsed(9), "9s"); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 8a12f6951..cc659f199 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -269,6 +269,17 @@ async fn set_controller_state( snapshot } +fn publish_context_usage( + shared_state: &Arc, + working_event_tx: &broadcast::Sender, + tokens: Option, + source: protocol::ContextTokenSource, +) { + shared_state.update_context_usage(tokens, source); + let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source }); + let _ = working_event_tx.send(Event::ContextUsage { usage }); +} + async fn set_controller_status( shared_state: &Arc, runtime_dir: &RuntimeDir, @@ -303,7 +314,9 @@ async fn finish_controller_run( // the terminal run boundary so reconnect snapshots cannot append stale // partial text/tool arguments after newer entries. worker.clear_in_flight_events(); - shared_state.update_context_usage( + publish_context_usage( + shared_state, + working_event_tx, Some(worker.total_tokens().tokens), protocol::ContextTokenSource::Estimated, ); @@ -797,9 +810,14 @@ impl WorkerController { greeting, )); let usage_state = shared_state.clone(); + let usage_events = working_event_tx.clone(); worker.engine_mut().on_usage(move |event| { - usage_state - .update_context_usage(event.input_tokens, protocol::ContextTokenSource::Measured); + publish_context_usage( + &usage_state, + &usage_events, + event.input_tokens, + protocol::ContextTokenSource::Measured, + ); }); if let Some(fs_for_view) = fs_for_view { shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); @@ -2518,7 +2536,9 @@ async fn controller_loop( } } }; - shared_state.update_context_usage( + publish_context_usage( + &shared_state, + &working_event_tx, Some(worker.total_tokens().tokens), protocol::ContextTokenSource::Estimated, ); @@ -2589,7 +2609,9 @@ async fn controller_loop( .await { worker.clear_in_flight_events(); - shared_state.update_context_usage( + publish_context_usage( + &shared_state, + &working_event_tx, Some(worker.total_tokens().tokens), protocol::ContextTokenSource::Estimated, ); @@ -3566,6 +3588,47 @@ mod tests { ); } + #[test] + fn context_usage_publication_updates_reconnect_state_and_live_clients() { + let shared_state = Arc::new(WorkerSharedState::new( + "worker".into(), + session_store::new_segment_id(), + String::new(), + protocol::Greeting { + worker_name: "worker".into(), + cwd: "/tmp".into(), + provider: "provider".into(), + model: "model".into(), + reasoning: None, + scope_summary: String::new(), + tools: Vec::new(), + context_window: 100_000, + context_tokens: 0, + context_usage: None, + }, + )); + let (events, mut receiver) = broadcast::channel(4); + + publish_context_usage( + &shared_state, + &events, + Some(25_000), + protocol::ContextTokenSource::Estimated, + ); + + let expected = protocol::ContextUsage { + tokens: 25_000, + source: protocol::ContextTokenSource::Estimated, + }; + assert_eq!(shared_state.greeting().context_usage, Some(expected)); + assert!(matches!( + receiver.try_recv(), + Ok(Event::ContextUsage { + usage: Some(usage) + }) if usage == expected + )); + } + #[test] fn automatic_memory_extraction_destinations_are_mutually_exclusive() { let mut memory = manifest::MemoryFeatureProfileConfig::default(); diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 0266f77c0..76c8b6b91 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -834,6 +834,23 @@ fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Se }); } +fn publish_internal_context_usage( + greeting: &RwLock, + event_tx: &broadcast::Sender, + tokens: Option, + source: protocol::ContextTokenSource, +) { + let usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source }); + { + let mut greeting = greeting + .write() + .expect("internal Worker greeting lock poisoned"); + greeting.context_tokens = tokens.unwrap_or_default(); + greeting.context_usage = usage; + } + let _ = event_tx.send(Event::ContextUsage { usage }); +} + pub(crate) async fn prepare_internal_worker_session( mut worker: Worker, EphemeralSessionStore>, store: EphemeralSessionStore, @@ -864,17 +881,17 @@ pub(crate) async fn prepare_internal_worker_session( let greeting = Arc::new(RwLock::new(build_greeting(&worker))); wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight); let usage_greeting = greeting.clone(); + let usage_events = event_tx.clone(); worker.engine_mut().on_usage(move |event| { - let mut greeting = usage_greeting - .write() - .expect("internal Worker greeting lock poisoned"); - greeting.context_tokens = event.input_tokens.unwrap_or_default(); - greeting.context_usage = event.input_tokens.map(|tokens| protocol::ContextUsage { - tokens, - source: protocol::ContextTokenSource::Measured, - }); + publish_internal_context_usage( + &usage_greeting, + &usage_events, + event.input_tokens, + protocol::ContextTokenSource::Measured, + ); }); + let actor_greeting = greeting.clone(); let session_id = worker.session_id(); let segment_id = worker.segment_id(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8); @@ -909,50 +926,56 @@ pub(crate) async fn prepare_internal_worker_session( match command { InternalWorkerSessionCommand::Run(input) => { actor_in_flight.clear(); - let cancel_sender = worker.engine_mut().cancel_sender(); - let mut run = std::pin::pin!(worker.run_text(&input)); - loop { - tokio::select! { - result = &mut run => { - let (turn_status, error, outcome) = - classify_internal_turn_result(result); - actor_in_flight.clear(); - *last_outcome.lock().unwrap() = Some(outcome); - status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); - if let Some(message) = error { - *last_error.lock().unwrap() = Some(message.clone()); - let _ = event_tx.send(Event::Error { - code: protocol::ErrorCode::Internal, - message, - }); + let (turn_status, error, outcome) = { + let cancel_sender = worker.engine_mut().cancel_sender(); + let mut run = std::pin::pin!(worker.run_text(&input)); + loop { + tokio::select! { + result = &mut run => { + break classify_internal_turn_result(result); } - send_internal_worker_state(&event_tx, turn_status); - if let Some(callback) = &on_turn_end { - callback(turn_status); - } - state_changed.notify_waiters(); - break; - } - command = command_rx.recv() => { - match command { - Some(InternalWorkerSessionCommand::Stop(done)) => { - let _ = cancel_sender.send(()).await; - let _ = (&mut run).await; - stop_done = Some(done); - break 'actor; - } - Some(InternalWorkerSessionCommand::Run(_)) => { - // `send` reserves Running atomically, so a second Run cannot be enqueued. - } - None => { - let _ = cancel_sender.send(()).await; - let _ = (&mut run).await; - break 'actor; + command = command_rx.recv() => { + match command { + Some(InternalWorkerSessionCommand::Stop(done)) => { + let _ = cancel_sender.send(()).await; + let _ = (&mut run).await; + stop_done = Some(done); + break 'actor; + } + Some(InternalWorkerSessionCommand::Run(_)) => { + // `send` reserves Running atomically, so a second Run cannot be enqueued. + } + None => { + let _ = cancel_sender.send(()).await; + let _ = (&mut run).await; + break 'actor; + } } } } } + }; + publish_internal_context_usage( + &actor_greeting, + &event_tx, + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); + actor_in_flight.clear(); + *last_outcome.lock().unwrap() = Some(outcome); + status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); + if let Some(message) = error { + *last_error.lock().unwrap() = Some(message.clone()); + let _ = event_tx.send(Event::Error { + code: protocol::ErrorCode::Internal, + message, + }); } + send_internal_worker_state(&event_tx, turn_status); + if let Some(callback) = &on_turn_end { + callback(turn_status); + } + state_changed.notify_waiters(); } InternalWorkerSessionCommand::Stop(done) => { stop_done = Some(done); @@ -1509,11 +1532,37 @@ permission = "write" assert_eq!(handle.protocol_snapshot().in_flight.blocks.len(), 1); let entries_after_first = handle.entries().len(); assert!(entries_after_first >= 4); + assert!(matches!( + handle + .protocol_snapshot() + .greeting + .and_then(|greeting| greeting.context_usage), + Some(protocol::ContextUsage { + source: protocol::ContextTokenSource::Estimated, + .. + }) + )); + let mut events = handle.subscribe_events(); handle.send("follow-up").await.expect("send follow-up turn"); assert_eq!( handle.wait_until_idle().await, InternalWorkerSessionStatus::Idle ); + let mut saw_estimated_context = false; + while let Ok(event) = events.try_recv() { + if matches!( + event, + Event::ContextUsage { + usage: Some(protocol::ContextUsage { + source: protocol::ContextTokenSource::Estimated, + .. + }) + } + ) { + saw_estimated_context = true; + } + } + assert!(saw_estimated_context); assert!(handle.protocol_snapshot().in_flight.blocks.is_empty()); assert_eq!(calls.load(Ordering::SeqCst), 2); assert!(handle.entries().len() > entries_after_first); diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index 0a1eff3c9..d364f42df 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"revision":1,"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 7212a00fb..b5f4ab628 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -381,7 +381,7 @@ summary: string, * Full tool output. Absent when the tool chose to return * summary-only, or when the result was pruned. */ -output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, +output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "context_usage", "data": { usage?: ContextUsage | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, /** * Full revisioned live execution state. `Stopped` remains Runtime * catalog authority and is deliberately not represented here. diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts index 56003bf51..f45776f62 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -39,4 +39,15 @@ test("renders honest unavailable state instead of a fabricated zero percent", as }); expect(view.container.textContent).toContain("claude · 8.2k token budget"); expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)"); + + await view.rerender({ + metadata: { + model: "claude", + reasoning: { kind: "budget_tokens", budget_tokens: -1 }, + contextWindow: 64_000, + contextTokens: 12_000, + contextSource: "estimated", + }, + }); + expect(view.container.textContent).toContain("claude · dynamic token budget"); }); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 14c7cdf5e..f5b43718b 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -224,14 +224,17 @@ Deno.test("snapshot and usage keep current context separate from run traffic", ( cache_read_input_tokens: 140_000, output_tokens: 2_000, } }, observedAtMs: 2 }, + { eventId: "context", event: { event: "context_usage", data: { + usage: { tokens: 158_000, source: "estimated" }, + } }, observedAtMs: 3 }, ]); assertEquals(projection.workerMetadata, { model: "gpt-6-astra", reasoning: { kind: "effort", effort: "high" }, contextWindow: 272_000, - contextTokens: 150_000, - contextSource: "measured", + contextTokens: 158_000, + contextSource: "estimated", }); assertEquals(projection.runActivity.uploadTokens, 10_000); assertEquals(projection.runActivity.outputTokens, 2_000); @@ -279,6 +282,20 @@ Deno.test("unknown and destructive boundaries never fabricate context percentage ]); assertEquals(projection.workerMetadata?.contextTokens, null); assertEquals(projection.workerMetadata?.contextSource, null); + + projection = projectConsole([ + { eventId: "live", event: live }, + { eventId: "rewind", event: { event: "rewind_applied", data: { + session: canonicalSession([]), + input: [], + summary: { truncated_to_entries: 0, discarded_entries: 1, tool_side_effect_warning: false }, + } } }, + { eventId: "post-rewind-context", event: { event: "context_usage", data: { + usage: { tokens: 12, source: "estimated" }, + } } }, + ]); + assertEquals(projection.workerMetadata?.contextTokens, 12); + assertEquals(projection.workerMetadata?.contextSource, "estimated"); }); Deno.test("large paste segments project compact artifact metadata", () => { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 2cbadce14..26e2772ce 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -3,6 +3,7 @@ import type { CommandEvent, CommandSnapshot, CommandStreamSlice, + ContextUsage, Event as ProtocolEvent, Greeting, InFlightBlock, @@ -256,6 +257,20 @@ function metadataWithUsage( }; } +function metadataWithContextUsage( + metadata: ConsoleWorkerMetadata | null, + usage: ContextUsage | null | undefined, +): ConsoleWorkerMetadata { + const tokens = safeTokenCount(usage?.tokens); + return { + model: metadata?.model ?? null, + reasoning: metadata?.reasoning ?? null, + contextWindow: metadata?.contextWindow ?? null, + contextTokens: tokens, + contextSource: tokens === null ? null : usage?.source ?? null, + }; +} + function metadataWithoutContext( metadata: ConsoleWorkerMetadata | null, ): ConsoleWorkerMetadata | null { @@ -1127,6 +1142,12 @@ export function applyProtocolEvent( event.data.input_tokens, ); break; + case "context_usage": + next.workerMetadata = metadataWithContextUsage( + next.workerMetadata, + event.data.usage, + ); + break; case "error": if (hasDurableRunFailure(next, event.data.message)) break; next.lines.push( diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index 5ec81c96a..29de10947 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -58,6 +58,10 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" ); assertEquals( formatReasoning({ kind: "budget_tokens", budget_tokens: -1 }), + "dynamic token budget", + ); + assertEquals( + formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }), "reasoning unavailable", ); assertEquals(formatContextSummary(null), "Context unavailable"); diff --git a/web/workspace/src/lib/workspace/console/worker-metadata.ts b/web/workspace/src/lib/workspace/console/worker-metadata.ts index 7f534387c..4927c0b71 100644 --- a/web/workspace/src/lib/workspace/console/worker-metadata.ts +++ b/web/workspace/src/lib/workspace/console/worker-metadata.ts @@ -8,10 +8,13 @@ export function formatReasoning(reasoning: ReasoningConfig | null): string { const effort = reasoning.effort.trim(); return effort.length > 0 ? effort : "reasoning unavailable"; } - if ( - !Number.isSafeInteger(reasoning.budget_tokens) || - reasoning.budget_tokens <= 0 - ) { + if (!Number.isSafeInteger(reasoning.budget_tokens)) { + return "reasoning unavailable"; + } + if (reasoning.budget_tokens === -1) { + return "dynamic token budget"; + } + if (reasoning.budget_tokens <= 0) { return "reasoning unavailable"; } return `${formatRunTokens(reasoning.budget_tokens)} token budget`; From 07cbff9a5fe6ddf30001c273d27ace91e4c6ba38 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 16:25:43 +0900 Subject: [PATCH 24/31] fix(console): keep live context metadata race-free --- crates/tui/src/app.rs | 48 ++++++ crates/worker/src/internal_worker.rs | 10 +- crates/worker/src/ipc/protocol_session.rs | 48 +++++- crates/worker/src/spawn/registry.rs | 159 +++++++++++++++++- .../src/lib/workspace/console/model.test.ts | 48 ++++++ 5 files changed, 307 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0f45861d9..eeff41061 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -4092,6 +4092,54 @@ mod completion_flow_tests { assert_eq!(child_app.session_context_tokens, 12_000); } + #[test] + fn live_internal_worker_snapshot_installs_child_metadata_before_context_updates() { + let mut app = App::new("parent".into()); + let worker = test_internal_worker_snapshot("child", "research", 1).worker; + let mut greeting = test_greeting(); + greeting.worker_name = "research".into(); + greeting.model = "child-model".into(); + greeting.context_window = 64_000; + greeting.context_usage = Some(protocol::ContextUsage { + tokens: 12_000, + source: protocol::ContextTokenSource::Measured, + }); + + app.handle_worker_event(Event::InternalWorker { + worker: worker.clone(), + revision: 1, + event: Box::new(Event::Snapshot { + session: protocol::SessionSnapshot { + pending_submissions: protocol::PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }, + greeting, + state: WorkerStatus::Running.into(), + in_flight: Default::default(), + internal_workers: Vec::new(), + }), + }); + app.handle_worker_event(Event::InternalWorker { + worker, + revision: 2, + event: Box::new(Event::ContextUsage { + usage: Some(protocol::ContextUsage { + tokens: 14_000, + source: protocol::ContextTokenSource::Estimated, + }), + }), + }); + + let child = &app.internal_workers[0].app; + assert_eq!(child.greeting.as_ref().unwrap().model, "child-model"); + assert_eq!(child.context_window, 64_000); + assert_eq!(child.session_context_tokens, 14_000); + assert_eq!( + child.session_context_source, + Some(protocol::ContextTokenSource::Estimated) + ); + } + #[test] fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() { let mut app = App::new("parent".into()); diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 76c8b6b91..b431f2bf9 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -1220,6 +1220,14 @@ impl session_store::WorkerMetadataStore for EphemeralSessionStore { #[cfg(test)] pub(crate) fn test_internal_worker_session( visibility: InternalWorkerVisibility, +) -> (InternalWorkerSessionHandle, broadcast::Sender) { + test_internal_worker_session_with_greeting(visibility, None) +} + +#[cfg(test)] +pub(crate) fn test_internal_worker_session_with_greeting( + visibility: InternalWorkerVisibility, + greeting: Option, ) -> (InternalWorkerSessionHandle, broadcast::Sender) { let store = EphemeralSessionStore::default(); let session_id = session_store::new_session_id(); @@ -1252,7 +1260,7 @@ pub(crate) fn test_internal_worker_session( visibility, last_error: Arc::new(Mutex::new(None)), last_outcome: Arc::new(Mutex::new(None)), - greeting: None, + greeting: greeting.map(|greeting| Arc::new(RwLock::new(greeting))), child_registry: None, sink, fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index cf83db223..cdfcdf878 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -16,9 +16,23 @@ pub struct WorkerProtocolSessionStreams { pub events: broadcast::Receiver, } +fn subscribe_before_snapshot( + subscribe: impl FnOnce() -> Subscription, + snapshot: impl FnOnce() -> Snapshot, +) -> (Snapshot, Subscription) { + // Subscription must exist before any snapshot field is read. A concurrent + // state update can then appear in both lanes, but it can never be absent + // from both the snapshot and the queued live stream. + let subscription = subscribe(); + let snapshot = snapshot(); + (snapshot, subscription) +} + pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtocolSessionStreams { - let (snapshot_event, log_entries) = handle.snapshot_event_with_entry_subscription(); - let (alert_snapshot, events) = handle.alerter.subscribe_with_snapshot(); + let ((snapshot_event, log_entries), (alert_snapshot, events)) = subscribe_before_snapshot( + || handle.alerter.subscribe_with_snapshot(), + || handle.snapshot_event_with_entry_subscription(), + ); WorkerProtocolSessionStreams { snapshot_event, alert_snapshot, @@ -129,6 +143,36 @@ mod tests { } } + #[test] + fn context_publication_between_subscription_and_snapshot_cannot_be_missed() { + let (events, _) = broadcast::channel(4); + let context_tokens = std::cell::Cell::new(20_u64); + + let (snapshot_tokens, mut receiver) = subscribe_before_snapshot( + || events.subscribe(), + || { + context_tokens.set(25); + events + .send(Event::ContextUsage { + usage: Some(protocol::ContextUsage { + tokens: 25, + source: protocol::ContextTokenSource::Estimated, + }), + }) + .unwrap(); + context_tokens.get() + }, + ); + + assert_eq!(snapshot_tokens, 25); + assert!(matches!( + receiver.try_recv(), + Ok(Event::ContextUsage { + usage: Some(protocol::ContextUsage { tokens: 25, .. }) + }) + )); + } + #[test] fn persisted_run_transitions_keep_identity_across_live_and_snapshot_projection() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index bfd280e47..5b706f792 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -29,7 +29,9 @@ use tokio::sync::{Notify, broadcast}; use tracing::warn; use workdir::WorkdirScopeLeaseSet; -use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility}; +use crate::internal_worker::{ + InternalWorkerSessionHandle, InternalWorkerSessionSnapshot, InternalWorkerVisibility, +}; use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; use crate::runtime::worker_allocation; @@ -648,6 +650,14 @@ impl SpawnedWorkerRegistry { let protocol_emit_lock = record.protocol_emit_lock.clone(); let protocol_terminal = record.protocol_terminal.clone(); let mut child_rx = record.session.subscribe_events(); + publish_initial_internal_snapshot( + &parent_tx, + &worker, + &protocol_revision, + &protocol_emit_lock, + &protocol_terminal, + &record.session, + ); tokio::spawn(async move { loop { match child_rx.recv().await { @@ -710,6 +720,14 @@ impl SpawnedWorkerRegistry { let protocol_emit_lock = record.protocol_emit_lock.clone(); let protocol_terminal = record.protocol_terminal.clone(); let mut child_rx = record.session.subscribe_events(); + publish_initial_internal_snapshot( + &parent_tx, + &worker, + &protocol_revision, + &protocol_emit_lock, + &protocol_terminal, + &record.session, + ); tokio::spawn(async move { loop { match child_rx.recv().await { @@ -985,6 +1003,41 @@ impl SpawnedWorkerRegistry { } } +fn publish_initial_internal_snapshot( + parent_tx: &broadcast::Sender, + worker: &InternalWorkerRef, + protocol_revision: &AtomicU64, + protocol_emit_lock: &Mutex<()>, + protocol_terminal: &AtomicBool, + session: &InternalWorkerSessionHandle, +) { + let Some(event) = internal_worker_live_snapshot(session.protocol_snapshot()) else { + return; + }; + let _emit_guard = protocol_emit_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + if protocol_terminal.load(Ordering::Acquire) { + return; + } + let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + let _ = parent_tx.send(Event::InternalWorker { + worker: worker.clone(), + revision, + event: Box::new(event), + }); +} + +fn internal_worker_live_snapshot(snapshot: InternalWorkerSessionSnapshot) -> Option { + Some(Event::Snapshot { + session: snapshot.session, + greeting: snapshot.greeting?, + state: snapshot.status.into(), + in_flight: snapshot.in_flight, + internal_workers: snapshot.internal_workers, + }) +} + fn internal_worker_snapshot( worker: InternalWorkerRef, revision: u64, @@ -1122,7 +1175,10 @@ mod tests { use session_store::LogEntry; use super::*; - use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session}; + use crate::internal_worker::{ + InternalWorkerSessionStatus, test_internal_worker_session, + test_internal_worker_session_with_greeting, + }; fn registry() -> Arc { let scope = Scope::from_config(&ScopeConfig { @@ -1142,7 +1198,15 @@ mod tests { name: &str, visibility: InternalWorkerVisibility, ) -> (InternalSpawnedWorkerRecord, broadcast::Sender) { - let (session, sender) = test_internal_worker_session(visibility); + record_with_greeting(name, visibility, None).await + } + + async fn record_with_greeting( + name: &str, + visibility: InternalWorkerVisibility, + greeting: Option, + ) -> (InternalSpawnedWorkerRecord, broadcast::Sender) { + let (session, sender) = test_internal_worker_session_with_greeting(visibility, greeting); let root = std::path::PathBuf::from("/tmp"); let scope = Scope::from_config(&ScopeConfig { allow: vec![ScopeRule { @@ -1194,6 +1258,95 @@ mod tests { ) } + #[tokio::test] + async fn visible_internal_worker_starts_with_own_live_snapshot_metadata() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(16); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let greeting = protocol::Greeting { + worker_name: "research".into(), + cwd: "/child".into(), + provider: "provider".into(), + model: "child-model".into(), + reasoning: Some(protocol::ReasoningConfig::Effort { + effort: "high".into(), + }), + scope_summary: "child scope".into(), + tools: Vec::new(), + context_window: 64_000, + context_tokens: 12_000, + context_usage: Some(protocol::ContextUsage { + tokens: 12_000, + source: protocol::ContextTokenSource::Measured, + }), + }; + let (record, child_tx) = record_with_greeting( + "research", + InternalWorkerVisibility::ParentClient, + Some(greeting), + ) + .await; + registry + .internal_records + .lock() + .unwrap() + .push(record.clone()); + + registry.start_protocol_forwarding(record); + + let initial = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + initial, + Event::InternalWorker { + revision: 1, + event, + .. + } if matches!( + *event, + Event::Snapshot { + greeting: protocol::Greeting { + ref model, + context_window: 64_000, + .. + }, + .. + } if model == "child-model" + ) + )); + + child_tx + .send(Event::ContextUsage { + usage: Some(protocol::ContextUsage { + tokens: 14_000, + source: protocol::ContextTokenSource::Estimated, + }), + }) + .unwrap(); + let update = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + update, + Event::InternalWorker { + revision: 2, + event, + .. + } if matches!( + *event, + Event::ContextUsage { + usage: Some(protocol::ContextUsage { + tokens: 14_000, + source: protocol::ContextTokenSource::Estimated, + }) + } + ) + )); + } + #[tokio::test] async fn visible_internal_output_is_wrapped_after_registry_insertion() { let registry = registry(); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index f5b43718b..3f71f8d80 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3050,6 +3050,54 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { assertEquals(resolveConsoleWorkerView(projection, "missing").sessionId, null); }); +Deno.test("live-created Internal Worker receives own metadata without reconnect", () => { + const worker = { + session_id: "live-child", + name: "research", + parent_session_id: "parent-session", + kind: "sub_worker" as const, + }; + const childSnapshot = snapshotEvent("/child") as SnapshotEvent; + childSnapshot.data.greeting.worker_name = "research"; + childSnapshot.data.greeting.model = "child-model"; + childSnapshot.data.greeting.reasoning = { kind: "effort", effort: "high" }; + childSnapshot.data.greeting.context_window = 64_000; + childSnapshot.data.greeting.context_usage = { tokens: 12_000, source: "measured" }; + + const projection = projectConsole([ + { + eventId: "child-snapshot", + event: { + event: "internal_worker", + data: { worker, revision: 1, event: childSnapshot }, + }, + }, + { + eventId: "child-context", + event: { + event: "internal_worker", + data: { + worker, + revision: 2, + event: { + event: "context_usage", + data: { usage: { tokens: 14_000, source: "estimated" } }, + }, + }, + }, + }, + ]); + + assertEquals(projection.workerMetadata, null); + assertEquals(projection.internalWorkers[0].console.workerMetadata, { + model: "child-model", + reasoning: { kind: "effort", effort: "high" }, + contextWindow: 64_000, + contextTokens: 14_000, + contextSource: "estimated", + }); +}); + Deno.test("Internal Worker snapshot output continues without entering the parent view", () => { const worker = { session_id: "child-reconnect", From 3ae9b1c6d12f44d25ad597f8689a3935b7c9b987 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 16:36:06 +0900 Subject: [PATCH 25/31] test(workspace-server): update greeting fixture --- crates/workspace-server/src/server.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3314c3798..8d2672868 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -38229,10 +38229,12 @@ mod tests { cwd: String::new(), provider: "deterministic-workspace-server-test".to_string(), model: "deterministic-workspace-server-test".to_string(), + reasoning: None, scope_summary: "test execution snapshot".to_string(), tools: Vec::new(), context_window: 0, context_tokens: 0, + context_usage: None, }, state: protocol::WorkerStateSnapshot::initial(), in_flight: protocol::InFlightSnapshot { From ee6aa9de412af53aba631f69e65695538dc7bee7 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 16:41:29 +0900 Subject: [PATCH 26/31] feat: discover subject session history --- crates/runtime-api/src/lib.rs | 206 ++- crates/server-api/src/lib.rs | 289 ++++ crates/session-store/src/lib.rs | 9 + crates/session-store/src/public_index.rs | 1194 ++++++++++++++ crates/session-store/src/public_snapshot.rs | 2 +- .../session-store/src/worker_session_store.rs | 17 + crates/worker-runtime/src/http_server.rs | 3 + .../src/http_server/runtime_management_api.rs | 74 + crates/worker-runtime/src/lib.rs | 2 + crates/worker-runtime/src/retention.rs | 20 + crates/worker-runtime/src/runtime.rs | 152 ++ crates/worker-runtime/src/session_public.rs | 357 +++++ crates/worker-runtime/src/worker_source.rs | 7 + crates/worker/src/controller.rs | 9 + crates/worker/src/feature/builtin.rs | 1 + .../src/feature/builtin/memory_lifecycle.rs | 1 + .../src/feature/builtin/subjektiv_memory.rs | 4 + .../src/feature/builtin/subjektiv_session.rs | 817 ++++++++++ crates/worker/src/feature/session.rs | 3 + crates/worker/src/worker.rs | 11 + crates/workspace-server/src/hosts.rs | 107 ++ crates/workspace-server/src/latest_schema.sql | 11 + crates/workspace-server/src/retention.rs | 131 ++ crates/workspace-server/src/server.rs | 1425 ++++++++++++++++- crates/workspace-server/src/store.rs | 59 +- crates/workspace-server/src/subjektiv.rs | 175 ++ .../src/workspace_deletion.rs | 1 + docs/design/subjektiv-store.md | 40 + openapi/server-api.json | 42 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 34 files changed, 5167 insertions(+), 12 deletions(-) create mode 100644 crates/session-store/src/public_index.rs create mode 100644 crates/worker-runtime/src/session_public.rs create mode 100644 crates/worker/src/feature/builtin/subjektiv_session.rs diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 757ab0ee0..7eac0c840 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -692,6 +692,178 @@ pub enum WorkerSessionHistoryAvailability { }, } +/// Backend-selected storage source. This type is never exposed as model tool +/// input; Runtime paths remain behind the authenticated Workspace capability. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "storage", rename_all = "snake_case")] +pub enum SessionPublicSource { + Retained { worker_id: WorkerId }, + Archived { archive_id: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicToolPart { + Input, + Output, + Both, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicReadMode { + Compact, + Full, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicLineageKind { + Root, + Fork, + Compact, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicLineage { + pub kind: SessionPublicLineageKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_turn_index: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchRequest { + pub workspace_id: String, + pub source: SessionPublicSource, + pub expected_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + pub tool_part: SessionPublicToolPart, + pub offset: usize, + pub limit: usize, + pub max_scan_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicReadRequest { + pub workspace_id: String, + pub source: SessionPublicSource, + pub expected_session_id: String, + pub expected_generation: Option, + pub segment_id: String, + pub entry_ref: String, + pub mode: SessionPublicReadMode, + pub byte_offset: usize, + pub max_content_bytes: usize, + pub max_scan_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchItem { + pub segment_id: String, + pub entry_ref: String, + pub kind: SessionPublicEntryKind, + pub origin: protocol::SessionEntryProvenance, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + pub compact: String, + pub lineage: SessionPublicLineage, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchPage { + pub session_id: String, + pub generation: String, + pub items: Vec, + pub next_offset: Option, + pub has_more: bool, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_manifest: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicReadPage { + pub session_id: String, + pub generation: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SessionPublicEntryKind, + pub origin: protocol::SessionEntryProvenance, + pub lineage: SessionPublicLineage, + pub mode: SessionPublicReadMode, + pub content: String, + pub next_byte_offset: Option, + pub has_more: bool, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_manifest: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicUnavailableReason { + NotFound, + RetentionMissing, + RetentionExpired, + ArchiveIncomplete, + CorruptLog, + MigrationRequired, + StorageUnavailable, + InvalidCursor, + ResourceLimit, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum SessionPublicSearchAvailability { + Page { + page: SessionPublicSearchPage, + }, + Unavailable { + reason: SessionPublicUnavailableReason, + message: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum SessionPublicReadAvailability { + Page { + page: SessionPublicReadPage, + }, + Unavailable { + reason: SessionPublicUnavailableReason, + message: String, + }, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RetainedSessionIdentity { pub session_id: String, @@ -766,6 +938,18 @@ pub trait RuntimeApi { #[query] request: WorkerSessionHistoryRequest, ) -> Result; + #[post("/v1/session-public/search", status = 200, error_status = 400)] + async fn session_public_search( + &self, + #[body] request: SessionPublicSearchRequest, + ) -> Result; + + #[post("/v1/session-public/read", status = 200, error_status = 400)] + async fn session_public_read( + &self, + #[body] request: SessionPublicReadRequest, + ) -> Result; + #[post("/v1/workers", status = 200, error_status = 400)] async fn create_worker( &self, @@ -1112,6 +1296,26 @@ mod tests { }) } + async fn session_public_search( + &self, + _request: SessionPublicSearchRequest, + ) -> Result { + Ok(SessionPublicSearchAvailability::Unavailable { + reason: SessionPublicUnavailableReason::StorageUnavailable, + message: "unsupported".to_string(), + }) + } + + async fn session_public_read( + &self, + _request: SessionPublicReadRequest, + ) -> Result { + Ok(SessionPublicReadAvailability::Unavailable { + reason: SessionPublicUnavailableReason::StorageUnavailable, + message: "unsupported".to_string(), + }) + } + async fn create_worker( &self, _request: CreateWorkerRequest, @@ -1248,7 +1452,7 @@ mod tests { #[test] fn contract_inventory_is_complete_and_unique() { let operations = RuntimeApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 17); + assert_eq!(operations.len(), 19); let mut routes = operations .iter() .map(|operation| (format!("{:?}", operation.method), operation.path)) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 43d855b83..73c6d0741 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1191,6 +1191,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivMemoryBackendRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/session-history", + operation_id = "subjektiv_session_backend", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_session_backend( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: SubjektivSessionBackendRequest, + ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/consolidation", status = 200, @@ -4534,6 +4548,280 @@ pub struct SubjektivRecordSessionResponse { pub session_id: String, } +/// Model-visible storage selector for subject-attributed Sessions. Runtime, +/// Worker, archive and filesystem identities remain Host-bound. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionStorageFilter { + All, + Retained, + Archived, +} + +impl Default for SubjektivSessionStorageFilter { + fn default() -> Self { + Self::All + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionStorage { + Retained, + Archived, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionAvailability { + Available, + Unchecked, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default)] + pub storage: SubjektivSessionStorageFilter, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionToolPart { + Input, + Output, + Both, +} + +impl Default for SubjektivSessionToolPart { + fn default() -> Self { + Self::Both + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default)] + pub tool_part: SubjektivSessionToolPart, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionReadMode { + Compact, + Full, +} + +impl Default for SubjektivSessionReadMode { + fn default() -> Self { + Self::Compact + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionReadRequest { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + #[serde(default)] + pub mode: SubjektivSessionReadMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "operation", content = "input", rename_all = "snake_case")] +pub enum SubjektivSessionBackendOperation { + List(SubjektivSessionListRequest), + Search(SubjektivSessionSearchRequest), + Read(SubjektivSessionReadRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionBackendRequest { + pub operation: SubjektivSessionBackendOperation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListItem { + pub session_id: String, + /// Host attribution time. This is not represented as experience time. + pub attributed_at: String, + pub storage: SubjektivSessionStorage, + pub availability: SubjektivSessionAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionLineageKind { + Root, + Fork, + Compact, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionLineage { + pub kind: SubjektivSessionLineageKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_turn_index: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchItem { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SubjektivSessionEntryKind, + pub origin: protocol::SessionEntryProvenance, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + pub snippet: String, + pub snippet_truncated: bool, + pub lineage: SubjektivSessionLineage, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionCoverage { + Complete, + Partial, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionDiagnosticCode { + InvalidInput, + SubjectUnavailable, + StaleCursor, + NotFoundOrNotAuthorized, + RetentionMissing, + RetentionExpired, + ArchiveIncomplete, + CorruptLog, + RuntimeUnavailable, + MigrationRequired, + ResourceLimit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionIssue { + pub session_id: String, + pub code: SubjektivSessionDiagnosticCode, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, + pub coverage: SubjektivSessionCoverage, + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionReadResponse { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SubjektivSessionEntryKind, + pub origin: protocol::SessionEntryProvenance, + pub lineage: SubjektivSessionLineage, + pub mode: SubjektivSessionReadMode, + pub content: String, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionErrorResponse { + pub code: SubjektivSessionDiagnosticCode, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "result", content = "data", rename_all = "snake_case")] +pub enum SubjektivSessionBackendResult { + List(SubjektivSessionListResponse), + Search(SubjektivSessionSearchResponse), + Read(SubjektivSessionReadResponse), +} + +/// Expected discovery/read failures remain typed model-visible data instead of +/// being collapsed into an empty success or an unstructured transport error. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SubjektivSessionBackendResponse { + Ok { + result: SubjektivSessionBackendResult, + }, + Error { + error: SubjektivSessionErrorResponse, + }, +} + +/// Maximum serialized model-facing JSON content for every subject Session tool. +pub const SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024; +pub const SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES: usize = 16 * 1024; +pub const SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES: usize = 512; + /// Current lifecycle state of one confirmed subject Memory revision. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -12273,6 +12561,7 @@ mod openapi_artifact_tests { "subjektiv_stage_candidate", "subjektiv_record_session", "subjektiv_memory_backend", + "subjektiv_session_backend", "subjektiv_memory_consolidation", "workspace_worker_discovery", "workspace_worker_remove", diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index b1b427e37..a3f9a4207 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -75,6 +75,7 @@ pub mod history; mod legacy_session_log; pub mod logged_item; mod paste_artifact; +pub mod public_index; pub mod public_snapshot; pub mod segment; pub mod segment_log; @@ -95,6 +96,14 @@ pub use history::{ }; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use paste_artifact::PasteArtifactLimits; +pub use public_index::{ + DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES, + DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, + SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, + SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, + SessionPublicIndexReadError, SessionPublicIndexSegment, SessionPublicIndexToolPart, + read_session_public_index, +}; pub use public_snapshot::{ DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs new file mode 100644 index 000000000..5ed7693f5 --- /dev/null +++ b/crates/session-store/src/public_index.rs @@ -0,0 +1,1194 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use protocol::{ + Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshotEntry, + SessionSnapshotEntryData, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{LogEntry, SegmentId, SegmentOrigin, SessionId, StoreError, WorkerSessionStore}; + +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES: u64 = 64 * 1024 * 1024; +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS: usize = 64; +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES: usize = 100_000; +pub const SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES: usize = 512; + +/// Caller-owned resource bounds for one complete public Session index read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionPublicIndexLimits { + pub max_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +impl Default for SessionPublicIndexLimits { + fn default() -> Self { + Self { + max_bytes: DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, + max_segments: DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, + max_entries: DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES, + } + } +} + +/// Read failures are intentionally storage-oriented. Authorization and archive +/// availability remain the responsibility of the caller that selected this +/// canonical Session root. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SessionPublicIndexReadError { + #[error("Worker Session requires migration")] + MigrationRequired, + #[error("Worker Session is missing")] + Missing, + #[error("Worker Session is corrupt")] + Corrupt, + #[error("Worker Session storage is unavailable")] + Storage, + #[error("Worker Session public index exceeds a resource limit")] + ResourceLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndex { + pub session_id: String, + /// Content generation over the canonical Session identity, ordered Segment + /// identities, lineage, and committed log records. + pub generation: String, + /// Every persisted Segment, including empty-public and non-active branches, + /// in ascending `segment_id` order. + pub segments: Vec, + /// Persisted bytes actually consumed while constructing this generation. + pub scanned_bytes: u64, + /// Persisted records plus embedded history entries charged to the scan. + pub scanned_entries: usize, + pub entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexSegment { + pub segment_id: String, + pub lineage: SessionPublicIndexLineage, + pub entries: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexOriginKind { + Root, + Fork, + Compact, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexLineage { + pub origin_kind: SessionPublicIndexOriginKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_turn_index: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexToolPart { + Input, + Output, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexEntry { + pub segment_id: String, + /// Stable public reference. The same inherited logical entry intentionally + /// has the same ref in multiple Segments; `segment_id` disambiguates it. + pub entry_ref: String, + pub kind: SessionPublicIndexEntryKind, + pub provenance: SessionEntryProvenance, + pub timestamp: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + pub compact_text: String, + pub full_text: String, +} + +#[derive(Debug)] +struct LoadedSegment { + segment_id: SegmentId, + lineage: SessionPublicIndexLineage, + origin: Option, + entries: Vec, + max_turn_index: usize, +} + +/// Read every persisted Segment below one canonical `/session` root. +/// +/// This operation is strictly observational: it opens the current schema in +/// read-only mode, performs no migration or restore, ignores trace and artifact +/// files, and reads Segment logs without intentionally updating access time. +pub fn read_session_public_index( + session_root: &Path, + limits: SessionPublicIndexLimits, +) -> Result { + if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let store = WorkerSessionStore::open_read_only(session_root).map_err(map_store_open_error)?; + let session_id = store + .session_id() + .map_err(map_store_error)? + .ok_or(SessionPublicIndexReadError::Missing)?; + let segment_ids = store + .list_segments_read_only(session_id) + .map_err(map_store_error)?; + if segment_ids.len() > limits.max_segments { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let mut scanned_bytes = 0_u64; + let mut scanned_entries = 0_usize; + let mut loaded = Vec::with_capacity(segment_ids.len()); + let mut generation = Sha256::new(); + generation.update(b"yoi-session-public-index-v1\0"); + generation.update(session_id.as_bytes()); + + for segment_id in segment_ids { + let remaining = limits + .max_bytes + .checked_sub(scanned_bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + let (entries, bytes) = store + .read_all_read_only_bounded(session_id, segment_id, remaining) + .map_err(map_store_error)?; + scanned_bytes = scanned_bytes + .checked_add(bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + let units = persisted_entry_units(&entries); + scanned_entries = scanned_entries + .checked_add(units) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + if scanned_entries > limits.max_entries { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let (lineage, origin) = validate_segment_start(session_id, segment_id, &entries)?; + let max_turn_index = entries.iter().fold( + origin + .as_ref() + .map(|origin| origin.at_turn_index) + .unwrap_or(0), + |max_turn, entry| match entry { + LogEntry::TurnEnd { turn_count, .. } => max_turn.max(*turn_count), + _ => max_turn, + }, + ); + + generation.update(segment_id.as_bytes()); + generation.update((entries.len() as u64).to_be_bytes()); + for entry in &entries { + let encoded = + serde_json::to_vec(entry).map_err(|_| SessionPublicIndexReadError::Corrupt)?; + generation.update((encoded.len() as u64).to_be_bytes()); + generation.update(encoded); + } + + loaded.push(LoadedSegment { + segment_id, + lineage, + origin, + entries, + max_turn_index, + }); + } + + validate_lineage(&loaded)?; + + let mut entry_count = 0_usize; + let mut segments = Vec::with_capacity(loaded.len()); + for segment in loaded { + let committed = committed_conversation_records(&segment.entries); + let snapshot = crate::public_snapshot::project_session_snapshot_for_segment( + session_id, + Some(segment.segment_id), + &committed, + ); + let entries = project_index_entries(segment.segment_id, snapshot.entries)?; + entry_count = entry_count + .checked_add(entries.len()) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + segments.push(SessionPublicIndexSegment { + segment_id: segment.segment_id.to_string(), + lineage: segment.lineage, + entries, + }); + } + + Ok(SessionPublicIndex { + session_id: session_id.to_string(), + generation: URL_SAFE_NO_PAD.encode(generation.finalize()), + segments, + scanned_bytes, + scanned_entries, + entry_count, + }) +} + +fn validate_segment_start( + expected_session_id: SessionId, + segment_id: SegmentId, + entries: &[LogEntry], +) -> Result<(SessionPublicIndexLineage, Option), SessionPublicIndexReadError> { + let Some(LogEntry::AnnotatedSegmentStart { + session_id, + forked_from, + compacted_from, + .. + }) = entries.first() + else { + return Err(SessionPublicIndexReadError::Corrupt); + }; + if *session_id != expected_session_id + || entries + .iter() + .skip(1) + .any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. })) + { + return Err(SessionPublicIndexReadError::Corrupt); + } + + match (forked_from, compacted_from) { + (Some(_), Some(_)) => Err(SessionPublicIndexReadError::Corrupt), + (Some(origin), None) => validate_origin(segment_id, origin).map(|origin| { + ( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Fork, + parent_segment_id: Some(origin.segment_id.to_string()), + parent_turn_index: Some(origin.at_turn_index), + }, + Some(origin), + ) + }), + (None, Some(origin)) => validate_origin(segment_id, origin).map(|origin| { + ( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Compact, + parent_segment_id: Some(origin.segment_id.to_string()), + parent_turn_index: Some(origin.at_turn_index), + }, + Some(origin), + ) + }), + (None, None) => Ok(( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Root, + parent_segment_id: None, + parent_turn_index: None, + }, + None, + )), + } +} + +fn validate_origin( + segment_id: SegmentId, + origin: &SegmentOrigin, +) -> Result { + if origin.segment_id == segment_id { + return Err(SessionPublicIndexReadError::Corrupt); + } + Ok(origin.clone()) +} + +fn validate_lineage(segments: &[LoadedSegment]) -> Result<(), SessionPublicIndexReadError> { + let by_id: HashMap = segments + .iter() + .map(|segment| (segment.segment_id, segment)) + .collect(); + for segment in segments { + if let Some(origin) = &segment.origin { + let parent = by_id + .get(&origin.segment_id) + .ok_or(SessionPublicIndexReadError::Corrupt)?; + if origin.at_turn_index > parent.max_turn_index { + return Err(SessionPublicIndexReadError::Corrupt); + } + } + + let mut seen = HashSet::new(); + let mut current = segment; + while let Some(origin) = ¤t.origin { + if !seen.insert(current.segment_id) { + return Err(SessionPublicIndexReadError::Corrupt); + } + current = by_id + .get(&origin.segment_id) + .copied() + .ok_or(SessionPublicIndexReadError::Corrupt)?; + } + } + Ok(()) +} + +/// Project only conversation records whose run reached a durable public commit +/// boundary. Human input written before `Invoke` remains committed input, while +/// assistant/tool output accumulated by a cancelled, errored, abandoned, or +/// still-active run is discarded. A yielded run is a committed boundary and a +/// later `RunResumed` begins a new pending buffer. +fn committed_conversation_records(entries: &[LogEntry]) -> Vec { + let mut committed = Vec::with_capacity(entries.len()); + let mut pending_run: Option> = None; + + for entry in entries { + match entry { + LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => { + pending_run = Some(vec![entry.clone()]); + } + LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. } => { + if let Some(mut pending) = pending_run.take() { + pending.push(entry.clone()); + committed.append(&mut pending); + } else { + committed.push(entry.clone()); + } + } + LogEntry::RunCancelled { .. } + | LogEntry::RunErrored { .. } + | LogEntry::PausedTurnAbandoned { .. } => { + pending_run = None; + } + _ => { + if let Some(pending) = &mut pending_run { + pending.push(entry.clone()); + } else { + committed.push(entry.clone()); + } + } + } + } + + committed +} + +fn project_index_entries( + segment_id: SegmentId, + entries: Vec, +) -> Result, SessionPublicIndexReadError> { + let mut projected = Vec::new(); + let mut seen_refs = HashSet::new(); + let mut tool_names = HashMap::::new(); + + for entry in entries { + if entry.entry_id.is_empty() { + return Err(SessionPublicIndexReadError::Corrupt); + } + let entry_ref = format!("E{}", entry.entry_id); + if !seen_refs.insert(entry_ref.clone()) { + continue; + } + + let (kind, tool_part, tool_name, compact_source, full_text) = match entry.data { + SessionSnapshotEntryData::UserInput { segments } => { + let text = Segment::flatten_to_text(&segments); + ( + SessionPublicIndexEntryKind::User, + None, + None, + text.clone(), + text, + ) + } + SessionSnapshotEntryData::Message { role, content } => { + let text = content_parts_text(&content); + let kind = match role { + SessionMessageRole::User => SessionPublicIndexEntryKind::User, + SessionMessageRole::Assistant => SessionPublicIndexEntryKind::Assistant, + }; + (kind, None, None, text.clone(), text) + } + SessionSnapshotEntryData::ToolCall { + call_id, + name, + arguments, + } => { + tool_names.insert(call_id, name.clone()); + ( + SessionPublicIndexEntryKind::Tool, + Some(SessionPublicIndexToolPart::Input), + Some(name), + arguments.clone(), + arguments, + ) + } + SessionSnapshotEntryData::ToolResult { + call_id, + summary, + content, + .. + } => { + let full = content.unwrap_or_else(|| summary.clone()); + let compact = if summary.is_empty() { + full.clone() + } else { + summary + }; + ( + SessionPublicIndexEntryKind::Tool, + Some(SessionPublicIndexToolPart::Output), + tool_names.get(&call_id).cloned(), + compact, + full, + ) + } + // SystemItems, run transitions, and any future non-conversation + // public snapshot records do not cross this narrower index boundary. + _ => continue, + }; + + projected.push(SessionPublicIndexEntry { + segment_id: segment_id.to_string(), + entry_ref, + kind, + provenance: entry.provenance, + timestamp: entry.timestamp, + tool_part, + tool_name, + compact_text: truncate_utf8( + &compact_source, + SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, + ), + full_text, + }); + } + + Ok(projected) +} + +fn content_parts_text(parts: &[SessionContentPart]) -> String { + let mut output = String::new(); + for part in parts { + match part { + SessionContentPart::Text { text } => output.push_str(text), + SessionContentPart::Refusal { refusal } => output.push_str(refusal), + } + } + output +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + let marker = "…"; + let content_limit = max_bytes.saturating_sub(marker.len()); + let mut end = content_limit.min(value.len()); + while !value.is_char_boundary(end) { + end -= 1; + } + let mut truncated = value[..end].to_owned(); + if max_bytes >= marker.len() { + truncated.push_str(marker); + } + truncated +} + +fn persisted_entry_units(entries: &[LogEntry]) -> usize { + entries.iter().fold(0_usize, |total, entry| { + let embedded = match entry { + LogEntry::AnnotatedSegmentStart { history, .. } + | LogEntry::AnnotatedUserInput { history, .. } => history.len(), + LogEntry::AnnotatedAssistantItem { .. } + | LogEntry::AnnotatedToolResult { .. } + | LogEntry::AnnotatedSystemItem { .. } => 1, + _ => 0, + }; + total.saturating_add(1).saturating_add(embedded) + }) +} + +fn map_store_open_error(error: StoreError) -> SessionPublicIndexReadError { + match error { + StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + SessionPublicIndexReadError::Missing + } + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + SessionPublicIndexReadError::MigrationRequired + } + StoreError::Io(_) => SessionPublicIndexReadError::Storage, + StoreError::ReadLimitExceeded => SessionPublicIndexReadError::ResourceLimit, + _ => SessionPublicIndexReadError::Corrupt, + } +} + +fn map_store_error(error: StoreError) -> SessionPublicIndexReadError { + match error { + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + SessionPublicIndexReadError::MigrationRequired + } + StoreError::ReadLimitExceeded => SessionPublicIndexReadError::ResourceLimit, + StoreError::Io(_) => SessionPublicIndexReadError::Storage, + StoreError::NotFound(_) | StoreError::Serde(_) | StoreError::Corrupt { .. } => { + SessionPublicIndexReadError::Corrupt + } + _ => SessionPublicIndexReadError::Corrupt, + } +} + +#[cfg(test)] +mod tests { + use agen::EngineResult; + use agen::llm_client::RequestConfig; + use protocol::{InvokeKind, RunFailureKind, RunResumeSource, RunYieldReason}; + + use super::*; + use crate::{ + LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, LoggedSessionHistoryEntryId, + LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, Store, + SystemItem, + }; + + fn segment_id(value: u128) -> SegmentId { + uuid::Uuid::from_u128(value) + } + + fn history( + id: &str, + item: LoggedItem, + origin: LoggedSessionHistoryOrigin, + ) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.to_owned()), + origin, + derivation: None, + }, + } + } + + fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { + let origin = match role { + LoggedRole::User => LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".into(), + }, + LoggedRole::Assistant => LoggedSessionHistoryOrigin::ModelOutput { + worker: crate::LoggedWorkerSubject { + workspace_id: Some("workspace-1".into()), + runtime_id: Some("runtime-1".into()), + worker_id: "worker-1".into(), + }, + }, + LoggedRole::System => { + LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None } + } + }; + history( + id, + LoggedItem::Message { + role, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + origin, + ) + } + + fn start( + session_id: SessionId, + history: Vec, + forked_from: Option, + compacted_from: Option, + ) -> LogEntry { + LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: Some("hidden system prompt".into()), + config: RequestConfig::default(), + history, + forked_from, + compacted_from, + } + } + + fn create_store() -> (tempfile::TempDir, std::path::PathBuf, WorkerSessionStore) { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + let store = WorkerSessionStore::new(&root).unwrap(); + (temp, root, store) + } + + #[test] + fn indexes_all_segments_in_order_and_keeps_nonactive_branches() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let root_segment = segment_id(1); + let fork_segment = segment_id(2); + let compact_segment = segment_id(3); + let seed = message("seed", LoggedRole::User, "seed text"); + + store + .create_segment( + session_id, + compact_segment, + &[start( + session_id, + vec![seed.clone()], + None, + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 0, + }), + )], + ) + .unwrap(); + store + .create_segment( + session_id, + root_segment, + &[ + start(session_id, vec![seed.clone()], None, None), + LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("root-answer", LoggedRole::Assistant, "root answer"), + }, + ], + ) + .unwrap(); + store + .create_segment( + session_id, + fork_segment, + &[ + start( + session_id, + vec![seed], + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 0, + }), + None, + ), + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message("branch-answer", LoggedRole::Assistant, "branch answer"), + }, + ], + ) + .unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_eq!( + index + .segments + .iter() + .map(|segment| segment.segment_id.as_str()) + .collect::>(), + vec![ + root_segment.to_string(), + fork_segment.to_string(), + compact_segment.to_string(), + ] + ); + assert!( + index.segments[0] + .entries + .iter() + .any(|entry| entry.full_text == "root answer") + ); + assert!( + index.segments[1] + .entries + .iter() + .any(|entry| entry.full_text == "branch answer") + ); + assert_eq!( + index.segments[1].lineage, + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Fork, + parent_segment_id: Some(root_segment.to_string()), + parent_turn_index: Some(0), + } + ); + assert_eq!( + index.segments[2].lineage.origin_kind, + SessionPublicIndexOriginKind::Compact + ); + assert_eq!(index.segments[0].entries[0].entry_ref, "Eseed"); + assert_eq!(index.segments[1].entries[0].entry_ref, "Eseed"); + assert_eq!(index.segments[2].entries[0].entry_ref, "Eseed"); + assert!( + index + .segments + .iter() + .flat_map(|segment| &segment.entries) + .all(|entry| entry.segment_id + == index + .segments + .iter() + .find(|segment| segment.entries.contains(entry)) + .unwrap() + .segment_id) + ); + } + + #[test] + fn excludes_system_reasoning_and_attachment_bodies_and_maps_tool_output_name() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let segment_id = segment_id(10); + let worker = crate::LoggedWorkerSubject { + workspace_id: Some("workspace-1".into()), + runtime_id: Some("runtime-1".into()), + worker_id: "worker-1".into(), + }; + let log = vec![ + start( + session_id, + vec![ + message("system", LoggedRole::System, "hidden system entry"), + message("user", LoggedRole::User, "visible user"), + history( + "reasoning", + LoggedItem::Reasoning { + text: "hidden reasoning".into(), + summary: vec!["hidden summary".into()], + encrypted_content: Some("hidden ciphertext".into()), + signature: None, + }, + LoggedSessionHistoryOrigin::ModelOutput { + worker: worker.clone(), + }, + ), + history( + "tool-call", + LoggedItem::ToolCall { + call_id: "call-1".into(), + name: "Read".into(), + arguments: "{\"path\":\"README.md\"}".into(), + call_index: Some(0), + execution_id: None, + status: None, + }, + LoggedSessionHistoryOrigin::ModelOutput { + worker: worker.clone(), + }, + ), + history( + "tool-output", + LoggedItem::ToolResult { + call_id: "call-1".into(), + summary: "read result".into(), + content: Some("visible output".into()), + attachments: vec![crate::logged_item::LoggedAttachment::Image { + mime_type: "image/png".into(), + data: b"secret attachment body".to_vec(), + }], + disposition: agen::tool::ToolResultDisposition::Success, + is_error: false, + }, + LoggedSessionHistoryOrigin::ToolOutput { worker }, + ), + ], + None, + None, + ), + LogEntry::AnnotatedSystemItem { + ts: 2, + entry: LoggedSystemHistoryEntry { + item: SystemItem::Notification { + message: "hidden notification".into(), + body: "hidden system body".into(), + prompt_provenance: None, + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId("system-item".into()), + origin: LoggedSessionHistoryOrigin::BackendInstruction { + operation_id: None, + }, + derivation: None, + }, + }, + extensions: Vec::new(), + }, + ]; + store.create_segment(session_id, segment_id, &log).unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let entries = &index.segments[0].entries; + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].entry_ref, "Euser"); + assert_eq!(entries[0].provenance, SessionEntryProvenance::HumanInput); + assert_eq!( + entries[1].tool_part, + Some(SessionPublicIndexToolPart::Input) + ); + assert_eq!(entries[1].tool_name.as_deref(), Some("Read")); + assert_eq!( + entries[2].tool_part, + Some(SessionPublicIndexToolPart::Output) + ); + assert_eq!(entries[2].tool_name.as_deref(), Some("Read")); + assert_eq!(entries[2].compact_text, "read result"); + assert_eq!(entries[2].full_text, "visible output"); + let json = serde_json::to_string(&index).unwrap(); + for hidden in [ + "hidden system prompt", + "hidden system entry", + "hidden reasoning", + "hidden ciphertext", + "hidden notification", + "hidden system body", + "secret attachment body", + ] { + assert!(!json.contains(hidden), "public index leaked {hidden:?}"); + } + } + + #[test] + fn excludes_uncommitted_cancelled_and_failed_outputs_but_keeps_committed_outcomes() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let outcomes = vec![ + ( + segment_id(20), + "completed", + LogEntry::RunCompleted { + ts: 4, + interrupted: false, + result: EngineResult::Finished, + active_run_turn_count: None, + }, + ), + ( + segment_id(21), + "yielded", + LogEntry::RunYielded { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("yielded-transition".into())), + reason: RunYieldReason::Compaction, + active_run_turn_count: 1, + }, + ), + ( + segment_id(22), + "cancelled", + LogEntry::RunCancelled { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("cancelled-transition".into())), + }, + ), + ( + segment_id(23), + "errored", + LogEntry::RunErrored { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("errored-transition".into())), + interrupted: false, + message: "run failed".into(), + failure: Some(RunFailureKind::Compaction), + }, + ), + ( + segment_id(24), + "abandoned", + LogEntry::PausedTurnAbandoned { ts: 4 }, + ), + ]; + for (segment_id, text, outcome) in outcomes { + store + .create_segment( + session_id, + segment_id, + &[ + start( + session_id, + vec![message( + &format!("seed-{text}"), + LoggedRole::User, + "committed seed", + )], + None, + None, + ), + LogEntry::Invoke { + ts: 2, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message(text, LoggedRole::Assistant, text), + }, + outcome, + ], + ) + .unwrap(); + } + let active_segment = segment_id(25); + store + .create_segment( + session_id, + active_segment, + &[ + start( + session_id, + vec![message("active-seed", LoggedRole::User, "committed seed")], + None, + None, + ), + LogEntry::Invoke { + ts: 2, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message( + "uncommitted-output", + LoggedRole::Assistant, + "uncommitted active output", + ), + }, + ], + ) + .unwrap(); + let resumed_segment = segment_id(26); + store + .create_segment( + session_id, + resumed_segment, + &[ + start( + session_id, + vec![message( + "yielded-seed", + LoggedRole::Assistant, + "committed yielded seed", + )], + None, + None, + ), + LogEntry::RunResumed { + ts: 2, + entry_id: Some(LoggedSessionHistoryEntryId("resumed-transition".into())), + source: RunResumeSource::Compaction, + active_run_turn_count: 1, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message( + "uncommitted-resume", + LoggedRole::Assistant, + "uncommitted resumed output", + ), + }, + ], + ) + .unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let all_text: Vec<&str> = index + .segments + .iter() + .flat_map(|segment| &segment.entries) + .map(|entry| entry.full_text.as_str()) + .collect(); + for retained in ["completed", "yielded"] { + assert!(all_text.contains(&retained)); + } + for excluded in [ + "cancelled", + "errored", + "abandoned", + "uncommitted active output", + "uncommitted resumed output", + ] { + assert!(!all_text.contains(&excluded)); + } + assert!(all_text.contains(&"committed seed")); + assert!(all_text.contains(&"committed yielded seed")); + } + + #[test] + fn generation_is_stable_and_detects_append_replacement_and_segment_addition() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let first_segment = segment_id(30); + let initial = vec![start( + session_id, + vec![message("entry-1", LoggedRole::User, "one")], + None, + None, + )]; + store + .create_segment(session_id, first_segment, &initial) + .unwrap(); + + let first = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let unchanged = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_eq!(first.generation, unchanged.generation); + + store + .append( + session_id, + first_segment, + &LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("entry-2", LoggedRole::Assistant, "two"), + }, + ) + .unwrap(); + let appended = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(first.generation, appended.generation); + + store + .create_segment( + session_id, + first_segment, + &[start( + session_id, + vec![message("entry-1", LoggedRole::User, "replacement")], + None, + None, + )], + ) + .unwrap(); + let replaced = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(appended.generation, replaced.generation); + + store + .create_segment( + session_id, + segment_id(31), + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + let added = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(replaced.generation, added.generation); + } + + #[test] + fn enforces_byte_segment_and_entry_bounds_with_typed_errors() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + store + .create_segment( + session_id, + segment_id(40), + &[start( + session_id, + vec![message("entry-1", LoggedRole::User, "bounded")], + None, + None, + )], + ) + .unwrap(); + store + .create_segment( + session_id, + segment_id(41), + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_segments: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_entries: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_bytes: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index(&root.join("missing"), SessionPublicIndexLimits::default(),), + Err(SessionPublicIndexReadError::Missing) + ); + } + + #[test] + fn rejects_invalid_session_and_lineage_origins() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let root_segment = segment_id(50); + let child_segment = segment_id(51); + store + .create_segment( + session_id, + root_segment, + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + store + .create_segment( + session_id, + child_segment, + &[start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 1, + }), + None, + )], + ) + .unwrap(); + assert_eq!( + read_session_public_index(&root, SessionPublicIndexLimits::default()), + Err(SessionPublicIndexReadError::Corrupt) + ); + } + + #[test] + fn reports_migration_without_rewriting_the_manifest() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + std::fs::create_dir_all(root.join("segments")).unwrap(); + let session_id = crate::new_session_id(); + let manifest = format!("{{\"schema_version\":2,\"session_id\":\"{session_id}\"}}"); + std::fs::write(root.join("session.json"), &manifest).unwrap(); + + assert_eq!( + read_session_public_index(&root, SessionPublicIndexLimits::default()), + Err(SessionPublicIndexReadError::MigrationRequired) + ); + assert_eq!( + std::fs::read_to_string(root.join("session.json")).unwrap(), + manifest + ); + } +} diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 651e8d2c4..9fdadc373 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess project_session_snapshot_for_segment(session_id, None, log) } -fn project_session_snapshot_for_segment( +pub(crate) fn project_session_snapshot_for_segment( session_id: SessionId, segment_id: Option, log: &[LogEntry], diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index a23f50bb4..f6820cff1 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -468,6 +468,23 @@ impl WorkerSessionStore { Ok(()) } + /// Enumerate canonical Segment logs in ascending identity order without + /// creating or migrating state. Unlike the general [`Store`] listing, this + /// observation boundary rejects malformed names and non-regular files so a + /// persisted Segment cannot be silently omitted from a public index. + pub(crate) fn list_segments_read_only( + &self, + session_id: SessionId, + ) -> Result, StoreError> { + self.validate_retained_session(session_id)?; + segment_log_paths(&self.root).map(|paths| { + paths + .into_iter() + .map(|(segment_id, _)| segment_id) + .collect() + }) + } + pub fn segment_log_len(&self, segment_id: SegmentId) -> Result { Ok(fs::metadata(self.log_path(segment_id))?.len()) } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e3b5ca2c3..db90c1159 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2312,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s if path.starts_with("/v1/workers/") && *method == Method::DELETE { return Some("workers:delete"); } + if path.starts_with("/v1/session-public/") && *method == Method::POST { + return Some("workers:read"); + } if path.starts_with("/v1/workers/") && *method == Method::GET { return Some("workers:read"); } diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs index ec0045d06..c6992e09e 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi { .map_err(api_error) } + async fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_scope_required", + "Session public search requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_workspace_scope_mismatch", + "Session public search Workspace scope does not match the authenticated capability", + )); + } + #[cfg(feature = "fs-store")] + { + self.state + .runtime + .session_public_search_scoped(&scope, &request) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + #[cfg(not(feature = "fs-store"))] + { + Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public storage is unavailable".to_string(), + }) + } + } + + async fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_scope_required", + "Session public read requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_workspace_scope_mismatch", + "Session public read Workspace scope does not match the authenticated capability", + )); + } + #[cfg(feature = "fs-store")] + { + self.state + .runtime + .session_public_read_scoped(&scope, &request) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + #[cfg(not(feature = "fs-store"))] + { + Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public storage is unavailable".to_string(), + }) + } + } + async fn create_worker( &self, value: runtime_api::CreateWorkerRequest, diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 9c7c3df04..202cde59e 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -25,6 +25,8 @@ pub mod resource; #[cfg(feature = "fs-store")] pub mod retention; mod runtime; +#[cfg(feature = "fs-store")] +mod session_public; pub mod ssh_host_key_probe; pub mod worker_backend; pub mod worker_source; diff --git a/crates/worker-runtime/src/retention.rs b/crates/worker-runtime/src/retention.rs index 0d02fb3e1..a956ac1bf 100644 --- a/crates/worker-runtime/src/retention.rs +++ b/crates/worker-runtime/src/retention.rs @@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider { .join(archive_id)) } + pub(crate) fn session_archive( + &self, + archive_id: &str, + ) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> { + let archive_dir = self.archive_dir(archive_id)?; + let manifest: WorkerSessionArchiveManifest = read_json( + &archive_dir.join("manifest.json"), + "read Worker Session archive", + )?; + if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read Worker Session archive", + path: archive_dir.join("manifest.json"), + message: "archive manifest identity mismatch".to_string(), + }); + } + let manifest = validate_existing_archive(&archive_dir, &manifest)?; + Ok((manifest, archive_dir.join("session"))) + } + fn diagnostics_dir(&self, operation_id: &str) -> Result { validate_id("operation_id", operation_id)?; Ok(self diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index c80787cf3..e9e630069 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope { } const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256; + +#[cfg(feature = "fs-store")] +fn session_public_source_unavailable( + source: &runtime_api::SessionPublicSource, + error: &RuntimeError, +) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> { + use runtime_api::SessionPublicUnavailableReason as Reason; + + match (source, error) { + ( + runtime_api::SessionPublicSource::Retained { .. }, + RuntimeError::WorkerNotFound { .. }, + ) => Some(( + Reason::RetentionMissing, + "retained Session storage is missing", + )), + (runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => { + Some(( + Reason::ArchiveIncomplete, + "committed Session archive is incomplete", + )) + } + (runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => { + Some(( + Reason::ArchiveIncomplete, + "committed Session archive failed integrity validation", + )) + } + (_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => { + Some((Reason::StorageUnavailable, "Session storage is unavailable")) + } + _ => None, + } +} + const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed"; const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str = "Worker metadata deletion failed; the persisted Worker identity was retained for retry"; @@ -3543,6 +3578,123 @@ impl Runtime { ) } + /// Search the committed public projection of one Backend-selected retained + /// Session or verified archive without restoring a Worker. + #[cfg(feature = "fs-store")] + pub fn session_public_search_scoped( + &self, + scope: &RuntimeWorkspaceScope, + request: &runtime_api::SessionPublicSearchRequest, + ) -> Result { + let (session_root, archive) = + match self.resolve_session_public_source(scope, &request.source) { + Ok(source) => source, + Err(error) => { + if let Some((reason, message)) = + session_public_source_unavailable(&request.source, &error) + { + return Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason, + message: message.to_string(), + }); + } + return Err(error); + } + }; + Ok(crate::session_public::search( + &session_root, + request, + archive.as_ref(), + )) + } + + /// Read one exact public entry from a Backend-selected retained Session or + /// verified archive. No fallback Worker, Segment, or entry is selected. + #[cfg(feature = "fs-store")] + pub fn session_public_read_scoped( + &self, + scope: &RuntimeWorkspaceScope, + request: &runtime_api::SessionPublicReadRequest, + ) -> Result { + let (session_root, archive) = + match self.resolve_session_public_source(scope, &request.source) { + Ok(source) => source, + Err(error) => { + if let Some((reason, message)) = + session_public_source_unavailable(&request.source, &error) + { + return Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason, + message: message.to_string(), + }); + } + return Err(error); + } + }; + Ok(crate::session_public::read( + &session_root, + request, + archive.as_ref(), + )) + } + + #[cfg(feature = "fs-store")] + fn resolve_session_public_source( + &self, + scope: &RuntimeWorkspaceScope, + source: &runtime_api::SessionPublicSource, + ) -> Result< + ( + std::path::PathBuf, + Option, + ), + RuntimeError, + > { + let state = self.lock()?; + let store = state.fs_store().ok_or_else(|| { + RuntimeError::InvalidRequest( + "Session public reads require an fs-backed Runtime".to_string(), + ) + })?; + match source { + runtime_api::SessionPublicSource::Retained { worker_id } => { + let worker_ref = WorkerRef::new(*worker_id); + let worker = state.worker(&worker_ref)?; + if worker.workspace_id.as_deref() != Some(scope.workspace_id.as_str()) { + return Err(RuntimeError::WorkerNotFound { + worker_id: *worker_id, + }); + } + Ok(( + store + .runtime_dir() + .join("workers") + .join(worker_id.to_string()) + .join("session"), + None, + )) + } + runtime_api::SessionPublicSource::Archived { archive_id } => { + let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| { + RuntimeError::InvalidRequest( + "Runtime identity is not bound for Session archive reads".to_string(), + ) + })?; + let provider = FsWorkerRetentionProvider::new(store.runtime_dir()); + let (manifest, session_root) = provider.session_archive(archive_id)?; + if manifest.workspace_id != scope.workspace_id + || manifest.source_runtime_id != runtime_id + { + return Err(RuntimeError::InvalidRequest( + "Session archive scope does not match the authenticated Runtime capability" + .to_string(), + )); + } + Ok((session_root, Some(manifest))) + } + } + } + /// Enumerate host-authoritative Runtime inventory for Backend orphan /// reconciliation. Runtime identity and Workspace scope are derived here, /// not accepted in a diagnostic payload. diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs new file mode 100644 index 000000000..1ff39224c --- /dev/null +++ b/crates/worker-runtime/src/session_public.rs @@ -0,0 +1,357 @@ +use std::path::Path; + +use runtime_api::{ + SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind, + SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage, + SessionPublicReadRequest, SessionPublicSearchAvailability, SessionPublicSearchItem, + SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart, + SessionPublicUnavailableReason, WorkerSessionArchiveManifest, +}; +use session_store::{ + SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, + SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, + SessionPublicIndexReadError, SessionPublicIndexToolPart, read_session_public_index, +}; + +use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest; + +pub(crate) fn search( + session_root: &Path, + request: &SessionPublicSearchRequest, + archive_manifest: Option<&DomainArchiveManifest>, +) -> SessionPublicSearchAvailability { + if request.limit == 0 || request.limit > 100 { + return unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public search limit must be between 1 and 100", + ); + } + let index = match load_index( + session_root, + request.max_scan_bytes, + request.max_segments, + request.max_entries, + ) { + Ok(index) => index, + Err((reason, message)) => return unavailable(reason, message), + }; + if index.session_id != request.expected_session_id { + return unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + if request + .expected_generation + .as_deref() + .is_some_and(|expected| expected != index.generation) + { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search generation changed", + ); + } + + let query = request + .query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase); + let mut matches = Vec::new(); + for segment in &index.segments { + for entry in &segment.entries { + if !matches_entry(entry, request, query.as_deref()) { + continue; + } + matches.push(SessionPublicSearchItem { + segment_id: segment.segment_id.clone(), + entry_ref: entry.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + tool_name: entry.tool_name.clone(), + tool_part: entry.tool_part.map(tool_part), + compact: entry.compact_text.clone(), + lineage: lineage(&segment.lineage), + }); + } + } + if request.offset > matches.len() { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search position is stale", + ); + } + let end = request + .offset + .saturating_add(request.limit) + .min(matches.len()); + let items = matches[request.offset..end].to_vec(); + let has_more = end < matches.len(); + SessionPublicSearchAvailability::Page { + page: SessionPublicSearchPage { + session_id: index.session_id, + generation: index.generation, + items, + next_offset: has_more.then_some(end), + has_more, + scanned_bytes: index.scanned_bytes, + scanned_segments: index.segments.len(), + scanned_entries: index.scanned_entries, + archive_manifest: archive_manifest.map(api_archive_manifest), + }, + } +} + +pub(crate) fn read( + session_root: &Path, + request: &SessionPublicReadRequest, + archive_manifest: Option<&DomainArchiveManifest>, +) -> SessionPublicReadAvailability { + if request.max_content_bytes == 0 { + return read_unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public read content limit must be positive", + ); + } + let index = match load_index( + session_root, + request.max_scan_bytes, + request.max_segments, + request.max_entries, + ) { + Ok(index) => index, + Err((reason, message)) => return read_unavailable(reason, message), + }; + if index.session_id != request.expected_session_id { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + if request + .expected_generation + .as_deref() + .is_some_and(|expected| expected != index.generation) + { + return read_unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public read generation changed", + ); + } + let Some(segment) = index + .segments + .iter() + .find(|segment| segment.segment_id == request.segment_id) + else { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session entry was not found in the authorized source", + ); + }; + let mut entries = segment + .entries + .iter() + .filter(|entry| entry.entry_ref == request.entry_ref); + let Some(entry) = entries.next() else { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session entry was not found in the authorized source", + ); + }; + if entries.next().is_some() { + return read_unavailable( + SessionPublicUnavailableReason::CorruptLog, + "Session entry identity is ambiguous in its Segment", + ); + } + let content = match request.mode { + SessionPublicReadMode::Compact => &entry.compact_text, + SessionPublicReadMode::Full => &entry.full_text, + }; + if request.byte_offset > content.len() || !content.is_char_boundary(request.byte_offset) { + return read_unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public read position is stale", + ); + } + let mut end = request + .byte_offset + .saturating_add(request.max_content_bytes) + .min(content.len()); + while end > request.byte_offset && !content.is_char_boundary(end) { + end -= 1; + } + if end == request.byte_offset && request.byte_offset < content.len() { + return read_unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public read cannot fit one UTF-8 scalar in the content limit", + ); + } + let has_more = end < content.len(); + SessionPublicReadAvailability::Page { + page: SessionPublicReadPage { + session_id: index.session_id, + generation: index.generation, + segment_id: segment.segment_id.clone(), + entry_ref: entry.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + lineage: lineage(&segment.lineage), + mode: request.mode, + content: content[request.byte_offset..end].to_string(), + next_byte_offset: has_more.then_some(end), + has_more, + scanned_bytes: index.scanned_bytes, + scanned_segments: index.segments.len(), + scanned_entries: index.scanned_entries, + archive_manifest: archive_manifest.map(api_archive_manifest), + }, + } +} + +fn load_index( + session_root: &Path, + max_bytes: u64, + max_segments: usize, + max_entries: usize, +) -> Result { + read_session_public_index( + session_root, + SessionPublicIndexLimits { + max_bytes, + max_segments, + max_entries, + }, + ) + .map_err(map_index_error) +} + +fn matches_entry( + entry: &SessionPublicIndexEntry, + request: &SessionPublicSearchRequest, + query: Option<&str>, +) -> bool { + if request + .kind + .is_some_and(|kind| kind != entry_kind(entry.kind)) + { + return false; + } + if request.tool_name.is_some() || request.tool_part != SessionPublicToolPart::Both { + if entry.kind != SessionPublicIndexEntryKind::Tool { + return false; + } + } + if request + .tool_name + .as_deref() + .is_some_and(|name| entry.tool_name.as_deref() != Some(name)) + { + return false; + } + if request.tool_part != SessionPublicToolPart::Both + && entry.tool_part.map(tool_part) != Some(request.tool_part) + { + return false; + } + query.is_none_or(|query| entry.full_text.to_lowercase().contains(query)) +} + +fn entry_kind(kind: SessionPublicIndexEntryKind) -> SessionPublicEntryKind { + match kind { + SessionPublicIndexEntryKind::User => SessionPublicEntryKind::User, + SessionPublicIndexEntryKind::Assistant => SessionPublicEntryKind::Assistant, + SessionPublicIndexEntryKind::Tool => SessionPublicEntryKind::Tool, + } +} + +fn tool_part(part: SessionPublicIndexToolPart) -> SessionPublicToolPart { + match part { + SessionPublicIndexToolPart::Input => SessionPublicToolPart::Input, + SessionPublicIndexToolPart::Output => SessionPublicToolPart::Output, + } +} + +fn lineage(value: &SessionPublicIndexLineage) -> SessionPublicLineage { + SessionPublicLineage { + kind: match value.origin_kind { + SessionPublicIndexOriginKind::Root => SessionPublicLineageKind::Root, + SessionPublicIndexOriginKind::Fork => SessionPublicLineageKind::Fork, + SessionPublicIndexOriginKind::Compact => SessionPublicLineageKind::Compact, + }, + parent_segment_id: value.parent_segment_id.clone(), + at_turn_index: value.parent_turn_index, + } +} + +fn map_index_error( + error: SessionPublicIndexReadError, +) -> (SessionPublicUnavailableReason, &'static str) { + match error { + SessionPublicIndexReadError::MigrationRequired => ( + SessionPublicUnavailableReason::MigrationRequired, + "Session storage requires migration", + ), + SessionPublicIndexReadError::Missing => ( + SessionPublicUnavailableReason::RetentionMissing, + "Session storage is missing", + ), + SessionPublicIndexReadError::Corrupt => ( + SessionPublicUnavailableReason::CorruptLog, + "Session log is corrupt", + ), + SessionPublicIndexReadError::Storage => ( + SessionPublicUnavailableReason::StorageUnavailable, + "Session storage is unavailable", + ), + SessionPublicIndexReadError::ResourceLimit => ( + SessionPublicUnavailableReason::ResourceLimit, + "Session scan exceeds a resource limit", + ), + } +} + +fn unavailable( + reason: SessionPublicUnavailableReason, + message: impl Into, +) -> SessionPublicSearchAvailability { + SessionPublicSearchAvailability::Unavailable { + reason, + message: message.into(), + } +} + +fn read_unavailable( + reason: SessionPublicUnavailableReason, + message: impl Into, +) -> SessionPublicReadAvailability { + SessionPublicReadAvailability::Unavailable { + reason, + message: message.into(), + } +} + +fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiveManifest { + WorkerSessionArchiveManifest { + schema_version: manifest.schema_version, + archive_id: manifest.archive_id.clone(), + workspace_id: manifest.workspace_id.clone(), + source_runtime_id: manifest.source_runtime_id.clone(), + source_worker_id: manifest.source_worker_id, + source_session_id: manifest.source_session_id.clone(), + segment_ids: manifest.segment_ids.clone(), + source_created_at: manifest.source_created_at.clone(), + removed_at: manifest.removed_at.clone(), + archived_at_unix_seconds: manifest.archived_at_unix_seconds, + effective_profile: manifest.effective_profile.clone(), + retention_class: manifest.retention_class.clone(), + content_checksum_sha256: manifest.content_checksum_sha256.clone(), + content_bytes: manifest.content_bytes, + content_file_count: manifest.content_file_count, + policy_id: manifest.policy_id.clone(), + policy_revision: manifest.policy_revision, + operation_id: manifest.operation_id.clone(), + input_fingerprint: manifest.input_fingerprint.clone(), + } +} diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index db1ae1c11..bb700ae41 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -581,6 +581,13 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { client.subjektiv_memory_backend(workspace_id, request).await, ) } + WorkspaceServerOperation::SubjektivSession(request) => { + generated_workspace_response( + client + .subjektiv_session_backend(workspace_id, request) + .await, + ) + } } }) }) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 4a0d99bbe..a47d06d3f 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1307,6 +1307,15 @@ where { feature_registry.add_module(subjektiv_memory); } + if let Some(subjektiv_sessions) = + crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? + { + feature_registry.add_module(subjektiv_sessions); + } if let Some(memory_config) = memory_lifecycle_config && let Some(memory_lifecycle) = crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 3adbc5987..b1206d1bc 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -19,6 +19,7 @@ pub mod orchestration; mod resource_projection; pub mod session_explore; pub(crate) mod subjektiv_memory; +pub(crate) mod subjektiv_session; pub mod task; pub mod ticket; pub mod worker_observation; diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 3bdfc9759..d645a1670 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -2078,6 +2078,7 @@ permission = "write" segment_id: "segment-1".to_string(), session_revision: history_len.try_into().unwrap(), entry_count: history_len, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: (0..history_len) .map(|index| HistoryEntry { diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 9a901e1e7..15c0cc9e1 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -1101,6 +1101,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 1, entry_count: 0, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: Vec::new(), usage_history: Vec::new(), @@ -1351,6 +1352,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 1, entry_count: 3, + has_committed_run: true, run_exit: CommittedRunExit::Interrupted, history: vec![ crate::session_history::history_entry( @@ -1410,6 +1412,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 2, entry_count: 2, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: vec![ crate::session_history::history_entry( @@ -1487,6 +1490,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 2, entry_count: 2, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: vec![ crate::session_history::history_entry( diff --git a/crates/worker/src/feature/builtin/subjektiv_session.rs b/crates/worker/src/feature/builtin/subjektiv_session.rs new file mode 100644 index 000000000..d5f101477 --- /dev/null +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -0,0 +1,817 @@ +//! Host-scoped tools for exploring subject-attributed committed Sessions. +//! +//! Subject, Runtime, Worker, archive, and filesystem authority stays bound by +//! the Workspace host. Model input contains only the selectors and filters from +//! the public `server_api` request types. + +use std::sync::Arc; + +use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use async_trait::async_trait; +use schemars::JsonSchema; +use server_api::{ + SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivRecordSessionRequest, + SubjektivRecordSessionResponse, SubjektivSessionBackendOperation, + SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult, + SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest, + SubjektivSessionReadRequest, SubjektivSessionSearchRequest, +}; + +use crate::feature::session::CommittedSessionCaptureHandle; +use crate::feature::{ + FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, + HookDeclaration, ToolContribution, ToolDeclaration, +}; +use crate::hook::{ + Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, + HookPreRequestAction, PreLlmRequest, PreRequestContext, RunCommitted, RunCommittedContext, +}; +use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; + +const LIST_TOOL: &str = "SubjektivSessionList"; +const SEARCH_TOOL: &str = "SubjektivSessionSearch"; +const READ_TOOL: &str = "SubjektivSessionRead"; +const COMMIT_HOOK: &str = "record-subjektiv-session-after-commit"; +const RETRY_HOOK: &str = "retry-subjektiv-session-attribution"; + +const LIST_DESCRIPTION: &str = "List committed retained or archived Sessions attributed by the Host to the connected subject. Filter by exact session_id or storage and continue with the returned opaque cursor; subject, Runtime, Worker, archive, and filesystem authority are never model inputs."; +const SEARCH_DESCRIPTION: &str = "Search committed public entries across Host-authorized Sessions attributed to the connected subject, or list one Session's public entries when session_id is supplied and query is omitted. Search is case-insensitive literal matching; system prompts, hidden reasoning, traces, and attachment bodies are never exposed."; +const READ_DESCRIPTION: &str = "Read one exact committed public Session entry selected by session_id, segment_id, and entry_ref. Compact mode is the default; full mode remains bounded, and partial content must be continued with the returned opaque cursor."; + +#[derive(Clone)] +pub(crate) struct SubjektivSessionFeature { + state: SubjektivSessionState, +} + +#[derive(Clone)] +struct SubjektivSessionState { + client: Arc, + capture: CommittedSessionCaptureHandle, +} + +impl SubjektivSessionFeature { + pub(crate) fn from_resolved_config( + config: &manifest::ResolvedSubjektivFeatureConfig, + capture: CommittedSessionCaptureHandle, + client: Arc, + ) -> std::io::Result> { + if !config.profile.enabled { + return Ok(None); + } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !client.is_available() || client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv Session tools require Backend Workspace API authority", + )); + } + Ok(Some(Self { + state: SubjektivSessionState { client, capture }, + })) + } +} + +impl FeatureModule for SubjektivSessionFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("subjektiv-session-tools", "subjektiv Session Tools") + .with_description( + "Read-only discovery, search, and reading of Host-authorized subject Sessions.", + ) + .with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION)) + .with_tool(ToolDeclaration::new(SEARCH_TOOL, SEARCH_DESCRIPTION)) + .with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION)) + .with_hook(HookDeclaration::new( + COMMIT_HOOK, + FeatureHookPoint::RunCommitted, + )) + .with_hook(HookDeclaration::new( + RETRY_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for (name, description, operation) in [ + (LIST_TOOL, LIST_DESCRIPTION, SessionOperation::List), + (SEARCH_TOOL, SEARCH_DESCRIPTION, SessionOperation::Search), + (READ_TOOL, READ_DESCRIPTION, SessionOperation::Read), + ] { + context.tools().register(ToolContribution::new( + name, + tool_definition(name, description, self.state.clone(), operation), + ))?; + } + context.hooks().add_run_committed( + COMMIT_HOOK, + HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000), + RecordCommittedSessionHook { + state: self.state.clone(), + }, + )?; + context.hooks().add_pre_request( + RETRY_HOOK, + RetryCommittedSessionHook { + state: self.state.clone(), + }, + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionOperation { + List, + Search, + Read, +} + +fn tool_definition( + name: &'static str, + description: &'static str, + state: SubjektivSessionState, + operation: SessionOperation, +) -> ToolDefinition { + Arc::new(move || { + let schema = match operation { + SessionOperation::List => schema_for::(), + SessionOperation::Search => schema_for::(), + SessionOperation::Read => schema_for::(), + }; + let tool: Arc = Arc::new(SubjektivSessionTool { + state: state.clone(), + operation, + }); + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema), + tool, + ) + }) +} + +struct SubjektivSessionTool { + state: SubjektivSessionState, + operation: SessionOperation, +} + +#[async_trait] +impl Tool for SubjektivSessionTool { + async fn execute( + &self, + input_json: &str, + _context: ToolExecutionContext, + ) -> Result { + let operation = match self.operation { + SessionOperation::List => { + SubjektivSessionBackendOperation::List(parse(input_json, LIST_TOOL)?) + } + SessionOperation::Search => { + SubjektivSessionBackendOperation::Search(parse(input_json, SEARCH_TOOL)?) + } + SessionOperation::Read => { + SubjektivSessionBackendOperation::Read(parse(input_json, READ_TOOL)?) + } + }; + let response = self.state.execute(operation)?; + model_visible_response(response) + } +} + +impl SubjektivSessionState { + fn execute( + &self, + operation: SubjektivSessionBackendOperation, + ) -> Result { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivSession( + SubjektivSessionBackendRequest { operation }, + )) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + + if let Ok(response) = + serde_json::from_str::(&response.body) + { + return Ok(response); + } + let detail = serde_json::from_str::(&response.body) + .map(|error| error.message) + .unwrap_or(response.body); + Err(ToolError::ExecutionFailed(format!( + "subjektiv Session backend returned HTTP {}: {detail}", + response.status + ))) + } + + fn record_current_committed_session(&self) -> Result<(), HookError> { + let capture = self + .capture + .capture() + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !capture.has_committed_run { + return Err(HookError::new( + HookErrorCategory::Dependency, + "committed Session capture is not available yet", + )); + } + self.record_session(&capture.session_id) + } + + fn retry_current_committed_session(&self) -> Result<(), HookError> { + let capture = self + .capture + .capture() + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !capture.has_committed_run { + return Ok(()); + } + self.record_session(&capture.session_id) + } + + fn record_session(&self, session_id: &str) -> Result<(), HookError> { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession( + SubjektivRecordSessionRequest { + session_id: session_id.to_string(), + }, + )) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !response.is_success() { + return Err(HookError::new( + HookErrorCategory::Dependency, + format!( + "record subjektiv Session attribution returned HTTP {}", + response.status + ), + )); + } + let output: SubjektivRecordSessionResponse = + serde_json::from_str(&response.body).map_err(|error| { + HookError::new( + HookErrorCategory::Dependency, + format!("decode subjektiv Session attribution response: {error}"), + ) + })?; + if output.session_id != session_id { + return Err(HookError::new( + HookErrorCategory::Dependency, + "subjektiv Session attribution response changed session identity", + )); + } + Ok(()) + } +} + +struct RecordCommittedSessionHook { + state: SubjektivSessionState, +} + +#[async_trait] +impl Hook for RecordCommittedSessionHook { + async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> { + self.state.record_current_committed_session() + } +} + +struct RetryCommittedSessionHook { + state: SubjektivSessionState, +} + +#[async_trait] +impl Hook for RetryCommittedSessionHook { + async fn call(&self, _input: &PreRequestContext) -> Result { + self.state.retry_current_committed_session()?; + Ok(HookPreRequestAction::Continue) + } +} + +fn parse(input: &str, tool: &str) -> Result { + serde_json::from_str(input) + .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) +} + +fn schema_for() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({})) +} + +fn model_visible_response( + response: SubjektivSessionBackendResponse, +) -> Result { + let summary = response_summary(&response); + let content = serde_json::to_string(&response) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES { + return Ok(ToolOutput { + summary, + content: Some(content), + attachments: Vec::new(), + }); + } + + let bounded = SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::ResourceLimit, + message: "subjektiv Session response exceeded the 56 KiB model-visible JSON budget" + .to_string(), + }, + }; + let content = serde_json::to_string(&bounded) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + debug_assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES); + Ok(ToolOutput { + summary: "Subjektiv Session operation reached its output limit.".to_string(), + content: Some(content), + attachments: Vec::new(), + }) +} + +fn response_summary(response: &SubjektivSessionBackendResponse) -> String { + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(value), + } => format!("Listed {} subject Session(s).", value.items.len()), + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(value), + } => format!("Found {} subject Session entries.", value.items.len()), + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(value), + } => format!("Read subject Session entry {}.", value.entry_ref), + SubjektivSessionBackendResponse::Error { error } => { + format!("Subjektiv Session operation returned {:?}.", error.code) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::feature::session::{CommittedRunExit, CommittedSessionCapture}; + use crate::hook::{HookHistoryRange, HookInvocationContext, PreRequestInfo, RunCommittedExit}; + use crate::worker::{ + WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, + }; + + #[derive(Debug)] + struct SessionToolClient { + response: Mutex, + requests: Mutex>, + } + + impl SessionToolClient { + fn new(response: SubjektivSessionBackendResponse) -> Self { + Self { + response: Mutex::new(response), + requests: Mutex::new(Vec::new()), + } + } + } + + impl WorkspaceClient for SessionToolClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "subjektiv-session-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&*self.response.lock().unwrap()).unwrap(), + }) + } + } + + #[derive(Debug, Default)] + struct AttributionClient { + requests: Mutex>, + recorded: Mutex>, + insertions: AtomicUsize, + failures: AtomicUsize, + } + + impl WorkspaceClient for AttributionClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "subjektiv-attribution-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + if self + .failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Ok(WorkspaceResponse { + status: 503, + body: "temporarily unavailable".into(), + }); + } + let input: SubjektivRecordSessionRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + if self + .recorded + .lock() + .unwrap() + .insert(input.session_id.clone()) + { + self.insertions.fetch_add(1, Ordering::SeqCst); + } + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&SubjektivRecordSessionResponse { + subject_id: "subject-1".into(), + session_id: input.session_id, + }) + .unwrap(), + }) + } + } + + fn capture(has_committed_run: bool) -> CommittedSessionCaptureHandle { + CommittedSessionCaptureHandle::new(move || { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: usize::from(has_committed_run) as u64, + entry_count: usize::from(has_committed_run), + has_committed_run, + run_exit: CommittedRunExit::Finished, + history: Vec::new(), + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }) + } + + fn list_response() -> SubjektivSessionBackendResponse { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse { + items: Vec::new(), + next_cursor: None, + has_more: false, + }), + } + } + + fn state(client: Arc, has_committed_run: bool) -> SubjektivSessionState { + SubjektivSessionState { + client, + capture: capture(has_committed_run), + } + } + + fn feature(client: Arc) -> SubjektivSessionFeature { + SubjektivSessionFeature { + state: state(client, true), + } + } + + fn run_committed_context() -> RunCommittedContext { + RunCommittedContext { + invocation: HookInvocationContext { + workspace_id: Some("workspace-test".into()), + worker_id: "worker-1".into(), + session_id: "session-1".into(), + session_revision: 1, + run_id: Some("run-1".into()), + turn_index: Some(0), + call_id: None, + }, + exit: RunCommittedExit::Finished, + committed_history: HookHistoryRange::default(), + } + } + + fn pre_request_context() -> PreRequestContext { + PreRequestContext::new( + PreRequestInfo { + item_count: 0, + estimated_tokens: None, + turn_index: 0, + tool_calls_this_turn: 0, + }, + None, + ) + } + + #[test] + fn descriptor_and_definitions_register_exact_session_tools_and_typed_schemas() { + let feature = feature(Arc::new(SessionToolClient::new(list_response()))); + let descriptor = feature.descriptor(); + assert_eq!( + descriptor + .tools + .iter() + .map(|tool| (tool.name.as_str(), tool.description.as_str())) + .collect::>(), + vec![ + (LIST_TOOL, LIST_DESCRIPTION), + (SEARCH_TOOL, SEARCH_DESCRIPTION), + (READ_TOOL, READ_DESCRIPTION), + ] + ); + assert_eq!( + descriptor + .hooks + .iter() + .map(|hook| (hook.name.as_str(), hook.point.clone())) + .collect::>(), + vec![ + (COMMIT_HOOK, FeatureHookPoint::RunCommitted), + (RETRY_HOOK, FeatureHookPoint::PreLlmRequest), + ] + ); + + for (name, description, operation, expected_schema) in [ + ( + LIST_TOOL, + LIST_DESCRIPTION, + SessionOperation::List, + schema_for::(), + ), + ( + SEARCH_TOOL, + SEARCH_DESCRIPTION, + SessionOperation::Search, + schema_for::(), + ), + ( + READ_TOOL, + READ_DESCRIPTION, + SessionOperation::Read, + schema_for::(), + ), + ] { + let (meta, _) = tool_definition(name, description, feature.state.clone(), operation)(); + assert_eq!(meta.name, name); + assert_eq!(meta.description, description); + assert_eq!(meta.input_schema, expected_schema); + let properties = meta.input_schema["properties"].as_object().unwrap(); + for forbidden in [ + "subject_id", + "runtime_id", + "worker_id", + "archive_id", + "archive_path", + "path", + ] { + assert!(!properties.contains_key(forbidden)); + } + } + } + + #[test] + fn disabled_subjektiv_feature_registers_nothing() { + let config = manifest::ResolvedSubjektivFeatureConfig::default(); + let result = SubjektivSessionFeature::from_resolved_config( + &config, + capture(false), + Arc::new(SessionToolClient::new(list_response())), + ) + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn tools_reject_model_supplied_host_scope_and_storage_paths() { + let client = Arc::new(SessionToolClient::new(list_response())); + for (operation, input) in [ + ( + SessionOperation::List, + r#"{"subject_id":"subject-1","archive_path":"/tmp/archive"}"#, + ), + ( + SessionOperation::Search, + r#"{"query":"needle","runtime_id":"runtime-1","worker_id":"worker-1"}"#, + ), + ( + SessionOperation::Read, + r#"{"session_id":"session-1","segment_id":"segment-1","entry_ref":"E1","path":"session.jsonl"}"#, + ), + ] { + let error = SubjektivSessionTool { + state: state(client.clone(), true), + operation, + } + .execute(input, ToolExecutionContext::new("call", "batch", 0)) + .await + .unwrap_err(); + assert!(matches!(error, ToolError::InvalidArgument(_))); + } + assert!(client.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn tool_routes_one_host_bound_operation_to_session_history() { + let client = Arc::new(SessionToolClient::new(list_response())); + let tool = SubjektivSessionTool { + state: state(client.clone(), true), + operation: SessionOperation::List, + }; + let output = tool + .execute( + r#"{"storage":"retained","limit":20}"#, + ToolExecutionContext::new("call-1", "batch-1", 0), + ) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Post); + assert_eq!( + requests[0].path, + "/api/w/workspace-test/subjektiv/session-history" + ); + let body: SubjektivSessionBackendRequest = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert!(matches!( + body.operation, + SubjektivSessionBackendOperation::List(SubjektivSessionListRequest { + session_id: None, + storage: server_api::SubjektivSessionStorageFilter::Retained, + limit: Some(20), + cursor: None, + }) + )); + let visible: SubjektivSessionBackendResponse = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(_) + } + )); + } + + #[tokio::test] + async fn typed_errors_are_visible_and_oversized_json_becomes_bounded_resource_limit() { + let typed_error = SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::StaleCursor, + message: "restart the search".into(), + }, + }; + let error_tool = SubjektivSessionTool { + state: state(Arc::new(SessionToolClient::new(typed_error.clone())), true), + operation: SessionOperation::Search, + }; + let output = error_tool + .execute( + r#"{"query":"needle"}"#, + ToolExecutionContext::new("call-error", "batch", 0), + ) + .await + .unwrap(); + let visible: SubjektivSessionBackendResponse = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::StaleCursor, + .. + } + } + )); + + let oversized = SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse { + items: vec![server_api::SubjektivSessionListItem { + session_id: "session-1".into(), + attributed_at: "2026-01-01T00:00:00Z".into(), + storage: server_api::SubjektivSessionStorage::Retained, + availability: server_api::SubjektivSessionAvailability::Unavailable, + reason: Some("\\\u{0000}".repeat(SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES)), + }], + next_cursor: Some("cursor".into()), + has_more: true, + }), + }; + let output = model_visible_response(oversized).unwrap(); + let content = output.content.unwrap(); + assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES); + let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::ResourceLimit, + .. + } + } + )); + } + + #[tokio::test] + async fn candidate_free_commit_records_session_and_pre_request_waits_for_committed_capture() { + let client = Arc::new(AttributionClient::default()); + let commit_hook = RecordCommittedSessionHook { + state: state(client.clone(), true), + }; + commit_hook.call(&run_committed_context()).await.unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/w/workspace-test/subjektiv/sessions"); + assert!(!requests[0].path.contains("staging")); + drop(requests); + + let before_commit = Arc::new(AttributionClient::default()); + let premature_commit_hook = RecordCommittedSessionHook { + state: state(before_commit.clone(), false), + }; + assert!( + premature_commit_hook + .call(&run_committed_context()) + .await + .is_err() + ); + let retry_hook = RetryCommittedSessionHook { + state: state(before_commit.clone(), false), + }; + assert_eq!( + retry_hook.call(&pre_request_context()).await.unwrap(), + HookPreRequestAction::Continue + ); + assert!(before_commit.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn transient_attribution_failure_is_a_hook_failure_then_retries_exactly() { + let client = Arc::new(AttributionClient::default()); + client.failures.store(1, Ordering::SeqCst); + let state = state(client.clone(), true); + let commit_hook = RecordCommittedSessionHook { + state: state.clone(), + }; + let retry_hook = RetryCommittedSessionHook { state }; + + let error = commit_hook + .call(&run_committed_context()) + .await + .unwrap_err(); + assert_eq!(error.category, HookErrorCategory::Dependency); + assert_eq!( + retry_hook.call(&pre_request_context()).await.unwrap(), + HookPreRequestAction::Continue + ); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(client.insertions.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn repeated_commit_and_pre_request_use_exact_request_and_backend_dedupes() { + let client = Arc::new(AttributionClient::default()); + let state = state(client.clone(), true); + let commit_hook = RecordCommittedSessionHook { + state: state.clone(), + }; + let retry_hook = RetryCommittedSessionHook { state }; + + commit_hook.call(&run_committed_context()).await.unwrap(); + commit_hook.call(&run_committed_context()).await.unwrap(); + retry_hook.call(&pre_request_context()).await.unwrap(); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert!( + requests + .iter() + .all(|request| request.body == requests[0].body) + ); + assert_eq!(client.recorded.lock().unwrap().len(), 1); + assert_eq!(client.insertions.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/worker/src/feature/session.rs b/crates/worker/src/feature/session.rs index 0729f0af5..b31a27674 100644 --- a/crates/worker/src/feature/session.rs +++ b/crates/worker/src/feature/session.rs @@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture { /// Monotonic committed-log revision for the captured Segment. pub(crate) session_revision: u64, pub(crate) entry_count: usize, + /// Whether the durable capture contains a committed run boundary. This is + /// false for a newly materialized Session before its first Run commits. + pub(crate) has_committed_run: bool, pub(crate) run_exit: CommittedRunExit, pub(crate) history: Vec>, pub(crate) usage_history: Vec, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 78ffc91c7..1e0441297 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation { SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), SubjektivMemory(server_api::SubjektivMemoryBackendRequest), + SubjektivSession(server_api::SubjektivSessionBackendRequest), } fn workspace_server_json_request( @@ -716,6 +717,9 @@ fn workspace_server_operation_request( WorkspaceServerOperation::SubjektivMemory(request) => { workspace_server_json_request(format!("{base}/subjektiv/memory"), &request) } + WorkspaceServerOperation::SubjektivSession(request) => { + workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request) + } } } @@ -3488,6 +3492,12 @@ impl Worker { let entries = store .read_all(location.session_id, location.segment_id) .map_err(|error| FeatureSessionError::Capture(error.to_string()))?; + let has_committed_run = entries.iter().any(|entry| { + matches!( + entry, + LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. } + ) + }); let run_exit = entries .iter() .rev() @@ -3514,6 +3524,7 @@ impl Worker { segment_id: location.segment_id.to_string(), session_revision: entries.len().try_into().unwrap_or(u64::MAX), entry_count: entries.len(), + has_committed_run, run_exit, history, usage_history: restored.usage_history, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c197071cc..d5801d0bd 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { }) } + fn session_public_search( + &self, + _request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public search is not supported by this Runtime".to_string(), + }) + } + + fn session_public_read( + &self, + _request: runtime_api::SessionPublicReadRequest, + ) -> Result { + Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public read is not supported by this Runtime".to_string(), + }) + } + fn observation_source( &self, _worker_id: &str, @@ -2073,6 +2093,36 @@ impl RuntimeRegistry { }) } + pub fn session_public_search( + &self, + runtime_id: &str, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + self.runtime(runtime_id)? + .session_public_search(request) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "session_public_search_failed".to_string(), + message, + }) + } + + pub fn session_public_read( + &self, + runtime_id: &str, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + self.runtime(runtime_id)? + .session_public_read(request) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "session_public_read_failed".to_string(), + message, + }) + } + pub fn observation_source( &self, worker: &RuntimeWorkerRef, @@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .map_err(|error| error.to_string()) } + fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + if request.workspace_id != self.workspace_id { + return Err( + "Session public search Workspace does not match embedded Runtime".to_string(), + ); + } + let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend"); + self.runtime + .session_public_search_scoped(&scope, &request) + .map_err(|error| error.to_string()) + } + + fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + if request.workspace_id != self.workspace_id { + return Err( + "Session public read Workspace does not match embedded Runtime".to_string(), + ); + } + let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend"); + self.runtime + .session_public_read_scoped(&scope, &request) + .map_err(|error| error.to_string()) + } + fn observation_source( &self, worker_id: &str, @@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") { return "workers:delete"; } + if path.starts_with("/v1/session-public/") && method == "POST" { + return "workers:read"; + } if path.starts_with("/v1/workers/") && method == "GET" { return "workers:read"; } @@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .map_err(|diagnostic| diagnostic.message) } + fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.session_public_search(request).await }, + ) + .map_err(|diagnostic| diagnostic.message) + } + + fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.session_public_read(request).await }, + ) + .map_err(|diagnostic| diagnostic.message) + } + fn observation_source( &self, worker_id: &str, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index ab2178ed3..c640193f9 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives ( committed_at TEXT NOT NULL, expires_at TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE); +CREATE INDEX worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id,controller_runtime_id,controller_worker_id,archive_id); CREATE TABLE worker_tombstones ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 9847d8b5d..cd726fa0f 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -132,6 +132,21 @@ pub struct WorkerTombstone { pub operation_id: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerSessionArchiveRecord { + pub archive_id: String, + pub workspace_id: String, + pub worker: RuntimeWorkerRef, + pub session_id: String, + pub checksum_sha256: String, + pub content_bytes: u64, + pub policy_id: String, + pub policy_revision: u64, + pub operation_id: String, + pub committed_at: String, + pub expires_at: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerOrphanDiagnostic { pub diagnostic_id: String, @@ -454,6 +469,51 @@ impl SqliteWorkspaceStore { if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));} let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} }; tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?; + let observe_grants = { + let mut statement = tx.prepare( + "SELECT grant_id, controller_runtime_id, controller_worker_id, + permissions_json, created_at + FROM worker_control_grants + WHERE workspace_id = ?1 AND subject_runtime_id = ?2 + AND subject_worker_id = ?3 AND revoked_at IS NULL", + )?; + statement + .query_map( + params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + )? + .collect::, _>>()? + }; + for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants { + let permissions: Vec = serde_json::from_str(&permissions) + .map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?; + if permissions.iter().any(|permission| permission == "observe") { + tx.execute( + "INSERT OR IGNORE INTO worker_session_archive_observe_grants + (workspace_id,archive_id,controller_runtime_id,controller_worker_id, + subject_runtime_id,subject_worker_id,source_grant_id,granted_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace_id, + a.archive_id, + controller_runtime_id, + controller_worker_id, + plan.worker.runtime_id, + plan.worker.worker_id, + grant_id, + created_at + ], + )?; + } + } }else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));} match plan.diagnostics_disposition { DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())), @@ -592,6 +652,77 @@ impl SqliteWorkspaceStore { ) -> crate::Result> { self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from)) } + + pub fn worker_session_archive( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + session_id: &str, + ) -> crate::Result> { + self.with_conn(|connection| { + connection + .query_row( + "SELECT archive_id, checksum_sha256, content_bytes, policy_id, + policy_revision, operation_id, committed_at, expires_at + FROM worker_session_archives + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND session_id = ?4", + params![ + workspace_id, + worker.runtime_id, + worker.worker_id, + session_id + ], + |row| { + Ok(WorkerSessionArchiveRecord { + archive_id: row.get(0)?, + workspace_id: workspace_id.to_string(), + worker: worker.clone(), + session_id: session_id.to_string(), + checksum_sha256: row.get(1)?, + content_bytes: row.get::<_, i64>(2)? as u64, + policy_id: row.get(3)?, + policy_revision: row.get::<_, i64>(4)? as u64, + operation_id: row.get(5)?, + committed_at: row.get(6)?, + expires_at: row.get(7)?, + }) + }, + ) + .optional() + .map_err(StoreError::from) + }) + } + + pub fn worker_session_archive_observable_by( + &self, + workspace_id: &str, + archive_id: &str, + controller: &RuntimeWorkerRef, + subject: &RuntimeWorkerRef, + ) -> crate::Result { + self.with_conn(|connection| { + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM worker_session_archive_observe_grants + WHERE workspace_id = ?1 AND archive_id = ?2 + AND controller_runtime_id = ?3 AND controller_worker_id = ?4 + AND subject_runtime_id = ?5 AND subject_worker_id = ?6 + )", + params![ + workspace_id, + archive_id, + controller.runtime_id, + controller.worker_id, + subject.runtime_id, + subject.worker_id + ], + |row| row.get(0), + ) + .map_err(StoreError::from) + }) + } } fn orphan_diagnostic( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3314c3798..f7f8832d3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5461,6 +5461,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_memory_backend( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_session_backend( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -7170,6 +7173,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_session_backend( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::SubjektivSessionBackendRequest, + ) -> std::result::Result< + server_api::SubjektivSessionBackendResponse, + server_api::RepositoryApiError, + > { + scoped_subjektiv_session_backend( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_memory_consolidation( &self, context: server_api::ServerRequestContext, @@ -16958,6 +16981,1260 @@ async fn scoped_record_subjektiv_session( })) } +const SUBJEKTIV_SESSION_DEFAULT_LIMIT: usize = 20; +const SUBJEKTIV_SESSION_MAX_LIMIT: usize = 100; +const SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS: usize = 20; +// Session-public Runtime reads enforce the aggregate scan budget below. +const SUBJEKTIV_SESSION_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024; +const SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS: usize = 64; +const SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES: usize = 100_000; +const SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET: usize = 36 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionListCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + session_id: Option, + storage: server_api::SubjektivSessionStorageFilter, + snapshot_at: String, + after_attributed_at: String, + after_session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionSearchCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + query: Option, + session_id: Option, + kind: Option, + tool_name: Option, + tool_part: server_api::SubjektivSessionToolPart, + snapshot_at: String, + after_attributed_at: Option, + after_session_id: Option, + current: Option, + partial: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionSearchPosition { + attributed_at: String, + session_id: String, + runtime_id: String, + worker_id: String, + storage: server_api::SubjektivSessionStorage, + archive_id: Option, + generation: String, + offset: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionReadCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + session_id: String, + segment_id: String, + entry_ref: String, + mode: server_api::SubjektivSessionReadMode, + runtime_id: String, + worker_id: String, + storage: server_api::SubjektivSessionStorage, + archive_id: Option, + generation: String, + byte_offset: usize, +} + +#[derive(Debug, Clone)] +struct AuthorizedSubjectSession { + attribution: crate::subjektiv::SubjectSessionAttribution, + storage: server_api::SubjektivSessionStorage, + source: runtime_api::SessionPublicSource, + archive_id: Option, + archive: Option, + expired: bool, +} + +async fn scoped_subjektiv_session_backend( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, controller, authority) = + subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "Session history read", + )?; + let store = match open_subjektiv_store(&api) { + Ok(store) => store, + Err(error) => { + return Ok(Json(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ))); + } + }; + let response = match request.operation { + server_api::SubjektivSessionBackendOperation::List(input) => subjektiv_session_list( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + server_api::SubjektivSessionBackendOperation::Search(input) => subjektiv_session_search( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + server_api::SubjektivSessionBackendOperation::Read(input) => subjektiv_session_read( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + }; + Ok(Json(response)) +} + +fn subjektiv_session_list( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionListRequest, +) -> server_api::SubjektivSessionBackendResponse { + let limit = match subjektiv_session_limit(input.limit) { + Ok(limit) => limit, + Err(response) => return response, + }; + let session_id = match normalize_subjektiv_session_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let (snapshot_at, mut after) = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionListCursor = + match decode_subjektiv_session_cursor("session-list", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.session_id != session_id + || cursor.storage != input.storage + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session list cursor does not match the current subject scope or filters", + ); + } + ( + cursor.snapshot_at, + Some((cursor.after_attributed_at, cursor.after_session_id)), + ) + } + None => (Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), None), + }; + + let mut items = Vec::new(); + let mut scanned = 0usize; + let mut store_has_more = false; + while items.len() < limit && scanned < SUBJEKTIV_SESSION_MAX_LIMIT { + let page = match store.subject_session_attribution_page( + subject_id, + session_id.as_deref(), + &snapshot_at, + after + .as_ref() + .map(|(attributed_at, session_id)| (attributed_at.as_str(), session_id.as_str())), + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + bounded_subjektiv_session_message(&error.to_string()), + ); + } + }; + store_has_more = page.has_more; + let Some(attribution) = page.items.into_iter().next() else { + if session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + scanned += 1; + let previous_after = after.clone(); + after = Some(( + attribution.attributed_at.clone(), + attribution.session_id.clone(), + )); + let source = match authorized_subjektiv_session(api, workspace_id, controller, attribution) + { + Ok(Some(source)) => source, + Ok(None) => { + if session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + continue; + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if !subjektiv_storage_matches(input.storage, source.storage) { + if session_id.is_some() { + break; + } + continue; + } + let item = server_api::SubjektivSessionListItem { + session_id: source.attribution.session_id, + attributed_at: source.attribution.attributed_at, + storage: source.storage, + availability: if source.expired { + server_api::SubjektivSessionAvailability::Unavailable + } else { + server_api::SubjektivSessionAvailability::Unchecked + }, + reason: source + .expired + .then(|| "Committed Session archive retention has expired".to_string()), + }; + let mut bounded_items = items.clone(); + bounded_items.push(item.clone()); + if serde_json::to_vec(&bounded_items) + .map(|value| value.len() > SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET) + .unwrap_or(true) + { + if items.is_empty() || previous_after.is_none() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ResourceLimit, + "One Session list result exceeds the response budget", + ); + } + after = previous_after; + store_has_more = true; + break; + } + items.push(item); + if session_id.is_some() { + store_has_more = false; + break; + } + } + + let has_more = session_id.is_none() + && (store_has_more || scanned == SUBJEKTIV_SESSION_MAX_LIMIT) + && after.is_some(); + let next_cursor = if has_more { + let (after_attributed_at, after_session_id) = after.expect("checked above"); + match encode_subjektiv_cursor( + "session-list", + &SubjektivSessionListCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + session_id, + storage: input.storage, + snapshot_at, + after_attributed_at, + after_session_id, + }, + ) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + bounded_subjektiv_session_message(&error.error.to_string()), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { + items, + next_cursor, + has_more, + }, + ), + } +} + +fn subjektiv_session_read( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionReadRequest, +) -> server_api::SubjektivSessionBackendResponse { + let session_id = + match normalize_subjektiv_session_required_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let segment_id = + match normalize_subjektiv_session_required_selector(input.segment_id, "segment_id") { + Ok(value) => value, + Err(response) => return response, + }; + let entry_ref = + match normalize_subjektiv_session_required_selector(input.entry_ref, "entry_ref") { + Ok(value) => value, + Err(response) => return response, + }; + let cursor = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionReadCursor = + match decode_subjektiv_session_cursor("session-read", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.session_id != session_id + || cursor.segment_id != segment_id + || cursor.entry_ref != entry_ref + || cursor.mode != input.mode + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session read cursor does not match the current subject scope or entry", + ); + } + Some(cursor) + } + None => None, + }; + let page = match store.subject_session_attribution_page( + subject_id, + Some(&session_id), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + None, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session entry was not found or is not authorized", + ); + }; + let source = + match authorized_subjektiv_session(api, workspace_id, controller, attribution.clone()) { + Ok(Some(source)) => source, + Ok(None) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session entry was not found or is not authorized", + ); + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if source.expired { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::RetentionExpired, + "Committed Session archive retention has expired", + ); + } + if let Some(cursor) = cursor.as_ref() + && (cursor.runtime_id != attribution.runtime_id + || cursor.worker_id != attribution.worker_id + || !subjektiv_session_source_matches( + cursor.storage, + cursor.archive_id.as_deref(), + &source, + )) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session storage changed while the read cursor was active", + ); + } + let request = runtime_api::SessionPublicReadRequest { + workspace_id: workspace_id.to_string(), + source: source.source.clone(), + expected_session_id: session_id.clone(), + expected_generation: cursor.as_ref().map(|cursor| cursor.generation.clone()), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: subjektiv_session_read_mode(input.mode), + byte_offset: cursor.as_ref().map_or(0, |cursor| cursor.byte_offset), + max_content_bytes: server_api::SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES, + max_scan_bytes: SUBJEKTIV_SESSION_MAX_SCAN_BYTES, + max_segments: SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS, + max_entries: SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES, + }; + let availability = match api + .runtime + .session_public_read(&attribution.runtime_id, request) + { + Ok(availability) => availability, + Err(error) => return subjektiv_session_runtime_error(error), + }; + let page = match availability { + runtime_api::SessionPublicReadAvailability::Page { page } => page, + runtime_api::SessionPublicReadAvailability::Unavailable { reason, message } => { + return subjektiv_session_error(subjektiv_session_diagnostic_code(reason), message); + } + }; + if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + "Runtime Session archive does not match the committed catalog", + ); + } + if page.session_id != session_id + || page.segment_id != segment_id + || page.entry_ref != entry_ref + || page.mode != subjektiv_session_read_mode(input.mode) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session read response did not match the selected entry", + ); + } + let next_cursor = if page.has_more { + let Some(byte_offset) = page.next_byte_offset else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session read response omitted its continuation position", + ); + }; + match encode_subjektiv_cursor( + "session-read", + &SubjektivSessionReadCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + session_id: session_id.clone(), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: input.mode, + runtime_id: attribution.runtime_id, + worker_id: attribution.worker_id, + storage: source.storage, + archive_id: source.archive_id, + generation: page.generation.clone(), + byte_offset, + }, + ) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Read( + server_api::SubjektivSessionReadResponse { + session_id, + segment_id, + entry_ref, + kind: subjektiv_session_entry_kind(page.kind), + origin: page.origin, + lineage: subjektiv_session_lineage(page.lineage), + mode: input.mode, + content: page.content, + truncated: page.has_more, + next_cursor, + has_more: page.has_more, + }, + ), + } +} + +fn subjektiv_session_search( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionSearchRequest, +) -> server_api::SubjektivSessionBackendResponse { + let limit = match subjektiv_session_limit(input.limit) { + Ok(limit) => limit, + Err(response) => return response, + }; + let session_id = match normalize_subjektiv_session_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let query = input + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(str::to_string); + if query.as_ref().is_some_and(|query| query.len() > 4_096) { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "Session search query is too large", + ); + } + if query.is_none() && session_id.is_none() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "query is required when session_id is omitted", + ); + } + let tool_name = match normalize_subjektiv_session_selector(input.tool_name, "tool_name") { + Ok(value) => value, + Err(response) => return response, + }; + if input + .kind + .is_some_and(|kind| kind != server_api::SubjektivSessionEntryKind::Tool) + && (tool_name.is_some() || input.tool_part != server_api::SubjektivSessionToolPart::Both) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "tool_name and tool_part filters require kind=tool or no kind filter", + ); + } + + let mut state = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionSearchCursor = + match decode_subjektiv_session_cursor("session-search", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.query != query + || cursor.session_id != session_id + || cursor.kind != input.kind + || cursor.tool_name != tool_name + || cursor.tool_part != input.tool_part + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor does not match the current subject scope or filters", + ); + } + cursor + } + None => SubjektivSessionSearchCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + query: query.clone(), + session_id: session_id.clone(), + kind: input.kind, + tool_name: tool_name.clone(), + tool_part: input.tool_part, + snapshot_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + after_attributed_at: None, + after_session_id: None, + current: None, + partial: false, + }, + }; + + let mut items = Vec::new(); + let mut issues = Vec::new(); + let mut scanned_sessions = 0usize; + let mut remaining_scan_bytes = SUBJEKTIV_SESSION_MAX_SCAN_BYTES; + let mut remaining_segments = SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS; + let mut remaining_entries = SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES; + let mut has_more = false; + let mut successful_sources = 0usize; + + while items.len() < limit && scanned_sessions < SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS { + if remaining_scan_bytes == 0 || remaining_segments == 0 || remaining_entries == 0 { + has_more = true; + state.partial = true; + break; + } + let (attribution, expected_generation, offset, cursor_source) = if let Some(current) = + state.current.take() + { + let page = match store.subject_session_attribution_page( + subject_id, + Some(¤t.session_id), + &state.snapshot_at, + None, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution is no longer available", + ); + }; + if attribution.attributed_at != current.attributed_at + || attribution.runtime_id != current.runtime_id + || attribution.worker_id != current.worker_id + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution changed", + ); + } + ( + attribution, + Some(current.generation), + current.offset, + Some((current.storage, current.archive_id)), + ) + } else { + let page = match store.subject_session_attribution_page( + subject_id, + state.session_id.as_deref(), + &state.snapshot_at, + match ( + state.after_attributed_at.as_deref(), + state.after_session_id.as_deref(), + ) { + (Some(attributed_at), Some(session_id)) => Some((attributed_at, session_id)), + (None, None) => None, + _ => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor position is incomplete", + ); + } + }, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + if state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + has_more = page.has_more; + state.after_attributed_at = Some(attribution.attributed_at.clone()); + state.after_session_id = Some(attribution.session_id.clone()); + (attribution, None, 0, None) + }; + scanned_sessions += 1; + let source = match authorized_subjektiv_session( + api, + workspace_id, + controller, + attribution.clone(), + ) { + Ok(Some(source)) => source, + Ok(None) => { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + continue; + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if let Some((storage, archive_id)) = cursor_source.as_ref() + && !subjektiv_session_source_matches(*storage, archive_id.as_deref(), &source) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session storage changed while the search cursor was active", + ); + } + if source.expired { + let issue = server_api::SubjektivSessionIssue { + session_id: attribution.session_id.clone(), + code: server_api::SubjektivSessionDiagnosticCode::RetentionExpired, + message: "Committed Session archive retention has expired".to_string(), + }; + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error(issue.code, issue.message); + } + state.partial = true; + issues.push(issue); + continue; + } + let runtime_request = runtime_api::SessionPublicSearchRequest { + workspace_id: workspace_id.to_string(), + source: source.source.clone(), + expected_session_id: attribution.session_id.clone(), + expected_generation, + query: query.clone(), + kind: input.kind.map(runtime_session_entry_kind), + tool_name: tool_name.clone(), + tool_part: runtime_session_tool_part(input.tool_part), + offset, + limit: limit.saturating_sub(items.len()), + max_scan_bytes: remaining_scan_bytes, + max_segments: remaining_segments, + max_entries: remaining_entries, + }; + let availability = match api + .runtime + .session_public_search(&attribution.runtime_id, runtime_request) + { + Ok(availability) => availability, + Err(error) => { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_runtime_error(error); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id, + code: server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable, + message: bounded_subjektiv_session_message(&error.message()), + }); + continue; + } + }; + let page = match availability { + runtime_api::SessionPublicSearchAvailability::Page { page } => page, + runtime_api::SessionPublicSearchAvailability::Unavailable { reason, message } => { + let code = subjektiv_session_diagnostic_code(reason); + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error(code, message); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id, + code, + message: bounded_subjektiv_session_message(&message), + }); + continue; + } + }; + if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + "Runtime Session archive does not match the committed catalog", + ); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id.clone(), + code: server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + message: "Runtime Session archive does not match the committed catalog".to_string(), + }); + continue; + } + successful_sources += 1; + remaining_scan_bytes = remaining_scan_bytes.saturating_sub(page.scanned_bytes); + remaining_segments = remaining_segments.saturating_sub(page.scanned_segments); + remaining_entries = remaining_entries.saturating_sub(page.scanned_entries); + let generation = page.generation.clone(); + let mut consumed = 0usize; + for item in page.items { + let (snippet, snippet_truncated) = truncate_subjektiv_session_snippet(&item.compact); + let projected = server_api::SubjektivSessionSearchItem { + session_id: attribution.session_id.clone(), + segment_id: item.segment_id, + entry_ref: item.entry_ref, + kind: subjektiv_session_entry_kind(item.kind), + origin: item.origin, + tool_name: item.tool_name, + tool_part: item.tool_part.map(subjektiv_session_tool_part), + snippet, + snippet_truncated, + lineage: subjektiv_session_lineage(item.lineage), + }; + let mut bounded_items = items.clone(); + bounded_items.push(projected.clone()); + if serde_json::to_vec(&(bounded_items, &issues)) + .map(|value| value.len() > SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET) + .unwrap_or(true) + { + if consumed == 0 && items.is_empty() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ResourceLimit, + "One Session search result exceeds the response budget", + ); + } + has_more = true; + state.partial = true; + state.current = Some(SubjektivSessionSearchPosition { + attributed_at: attribution.attributed_at.clone(), + session_id: attribution.session_id.clone(), + runtime_id: attribution.runtime_id.clone(), + worker_id: attribution.worker_id.clone(), + storage: source.storage, + archive_id: source.archive_id.clone(), + generation: generation.clone(), + offset: offset.saturating_add(consumed), + }); + break; + } + items.push(projected); + consumed += 1; + } + if state.current.is_some() { + break; + } + if page.has_more { + let next_offset = page + .next_offset + .unwrap_or_else(|| offset.saturating_add(consumed)); + has_more = true; + state.current = Some(SubjektivSessionSearchPosition { + attributed_at: attribution.attributed_at, + session_id: attribution.session_id, + runtime_id: attribution.runtime_id, + worker_id: attribution.worker_id, + storage: source.storage, + archive_id: source.archive_id, + generation, + offset: next_offset, + }); + break; + } + if state.session_id.is_some() { + has_more = false; + break; + } + if items.len() >= limit { + has_more = true; + break; + } + } + + if scanned_sessions >= SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS && state.session_id.is_none() { + has_more = true; + state.partial = true; + } + if !issues.is_empty() && successful_sources == 0 && !has_more { + let issue = issues.remove(0); + return subjektiv_session_error(issue.code, issue.message); + } + let next_cursor = if has_more { + match encode_subjektiv_cursor("session-search", &state) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Search( + server_api::SubjektivSessionSearchResponse { + items, + next_cursor, + has_more, + coverage: if state.partial || has_more { + server_api::SubjektivSessionCoverage::Partial + } else { + server_api::SubjektivSessionCoverage::Complete + }, + issues, + }, + ), + } +} + +fn subjektiv_session_limit( + limit: Option, +) -> std::result::Result { + let limit = limit.unwrap_or(SUBJEKTIV_SESSION_DEFAULT_LIMIT); + if (1..=SUBJEKTIV_SESSION_MAX_LIMIT).contains(&limit) { + Ok(limit) + } else { + Err(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "Session history limit must be between 1 and 100", + )) + } +} + +fn normalize_subjektiv_session_selector( + value: Option, + name: &str, +) -> std::result::Result, server_api::SubjektivSessionBackendResponse> { + let Some(value) = value else { + return Ok(None); + }; + let normalized = value.trim(); + if normalized.is_empty() || normalized.len() > 512 || normalized.chars().any(char::is_control) { + return Err(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + format!("{name} is invalid"), + )); + } + Ok(Some(normalized.to_string())) +} + +fn normalize_subjektiv_session_required_selector( + value: String, + name: &str, +) -> std::result::Result { + normalize_subjektiv_session_selector(Some(value), name)?.ok_or_else(|| { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + format!("{name} is required"), + ) + }) +} + +fn decode_subjektiv_session_cursor( + kind: &str, + cursor: &str, +) -> std::result::Result { + decode_subjektiv_cursor(kind, cursor).map_err(|_| { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session history cursor is invalid or stale", + ) + }) +} + +fn subjektiv_session_error( + code: server_api::SubjektivSessionDiagnosticCode, + message: impl Into, +) -> server_api::SubjektivSessionBackendResponse { + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code, + message: bounded_subjektiv_session_message(&message.into()), + }, + } +} + +fn bounded_subjektiv_session_message(message: &str) -> String { + truncate_subjektiv_session_utf8(message, 512) +} + +fn truncate_subjektiv_session_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn subjektiv_storage_matches( + filter: server_api::SubjektivSessionStorageFilter, + storage: server_api::SubjektivSessionStorage, +) -> bool { + matches!(filter, server_api::SubjektivSessionStorageFilter::All) + || matches!( + (filter, storage), + ( + server_api::SubjektivSessionStorageFilter::Retained, + server_api::SubjektivSessionStorage::Retained + ) | ( + server_api::SubjektivSessionStorageFilter::Archived, + server_api::SubjektivSessionStorage::Archived + ) + ) +} + +fn authorized_subjektiv_session( + api: &WorkspaceApi, + workspace_id: &str, + controller: &RuntimeWorkerRef, + attribution: crate::subjektiv::SubjectSessionAttribution, +) -> std::result::Result, String> { + let target = RuntimeWorkerRef::new(&attribution.runtime_id, &attribution.worker_id); + let archive = api + .config_store + .worker_session_archive(workspace_id, &target, &attribution.session_id) + .map_err(|error| error.to_string())?; + if let Some(archive) = archive { + let authorized = api + .config_store + .worker_session_archive_observable_by( + workspace_id, + &archive.archive_id, + controller, + &target, + ) + .map_err(|error| error.to_string())?; + if !authorized { + return Ok(None); + } + return Ok(Some(AuthorizedSubjectSession { + attribution, + storage: server_api::SubjektivSessionStorage::Archived, + source: runtime_api::SessionPublicSource::Archived { + archive_id: archive.archive_id.clone(), + }, + archive_id: Some(archive.archive_id.clone()), + expired: archive.expires_at.as_deref().is_some_and(is_expired), + archive: Some(archive), + })); + } + + let authorized = api + .store + .get_active_worker_control_grant(workspace_id, controller, &target) + .map_err(|error| error.to_string())? + .is_some_and(|grant| { + grant + .permissions + .iter() + .any(|permission| permission == "observe") + }); + if !authorized { + return Ok(None); + } + let worker_id = worker_runtime::identity::WorkerId::parse(&attribution.worker_id) + .ok_or_else(|| "attributed Worker identity is invalid".to_string())?; + Ok(Some(AuthorizedSubjectSession { + attribution, + storage: server_api::SubjektivSessionStorage::Retained, + source: runtime_api::SessionPublicSource::Retained { worker_id }, + archive_id: None, + archive: None, + expired: false, + })) +} + +fn subjektiv_session_source_matches( + position_storage: server_api::SubjektivSessionStorage, + position_archive_id: Option<&str>, + source: &AuthorizedSubjectSession, +) -> bool { + position_storage == source.storage && position_archive_id == source.archive_id.as_deref() +} + +fn subjektiv_session_archive_manifest_matches( + source: &AuthorizedSubjectSession, + manifest: Option<&runtime_api::WorkerSessionArchiveManifest>, +) -> bool { + match (&source.archive, manifest) { + (None, None) => true, + (Some(expected), Some(actual)) => { + actual.archive_id == expected.archive_id + && actual.workspace_id == expected.workspace_id + && actual.source_runtime_id == expected.worker.runtime_id + && actual.source_worker_id.to_string() == expected.worker.worker_id + && actual.source_session_id == expected.session_id + && actual.content_checksum_sha256 == expected.checksum_sha256 + && actual.content_bytes == expected.content_bytes + && actual.policy_id == expected.policy_id + && actual.policy_revision == expected.policy_revision + && actual.operation_id == expected.operation_id + } + _ => false, + } +} + +fn subjektiv_session_runtime_error( + error: RuntimeRegistryError, +) -> server_api::SubjektivSessionBackendResponse { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable, + bounded_subjektiv_session_message(&error.message()), + ) +} + +fn subjektiv_session_diagnostic_code( + reason: runtime_api::SessionPublicUnavailableReason, +) -> server_api::SubjektivSessionDiagnosticCode { + match reason { + runtime_api::SessionPublicUnavailableReason::NotFound => { + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized + } + runtime_api::SessionPublicUnavailableReason::RetentionMissing => { + server_api::SubjektivSessionDiagnosticCode::RetentionMissing + } + runtime_api::SessionPublicUnavailableReason::RetentionExpired => { + server_api::SubjektivSessionDiagnosticCode::RetentionExpired + } + runtime_api::SessionPublicUnavailableReason::ArchiveIncomplete => { + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete + } + runtime_api::SessionPublicUnavailableReason::CorruptLog => { + server_api::SubjektivSessionDiagnosticCode::CorruptLog + } + runtime_api::SessionPublicUnavailableReason::MigrationRequired => { + server_api::SubjektivSessionDiagnosticCode::MigrationRequired + } + runtime_api::SessionPublicUnavailableReason::StorageUnavailable => { + server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable + } + runtime_api::SessionPublicUnavailableReason::InvalidCursor => { + server_api::SubjektivSessionDiagnosticCode::StaleCursor + } + runtime_api::SessionPublicUnavailableReason::ResourceLimit => { + server_api::SubjektivSessionDiagnosticCode::ResourceLimit + } + } +} + +fn subjektiv_session_entry_kind( + kind: runtime_api::SessionPublicEntryKind, +) -> server_api::SubjektivSessionEntryKind { + match kind { + runtime_api::SessionPublicEntryKind::User => server_api::SubjektivSessionEntryKind::User, + runtime_api::SessionPublicEntryKind::Assistant => { + server_api::SubjektivSessionEntryKind::Assistant + } + runtime_api::SessionPublicEntryKind::Tool => server_api::SubjektivSessionEntryKind::Tool, + } +} + +fn runtime_session_entry_kind( + kind: server_api::SubjektivSessionEntryKind, +) -> runtime_api::SessionPublicEntryKind { + match kind { + server_api::SubjektivSessionEntryKind::User => runtime_api::SessionPublicEntryKind::User, + server_api::SubjektivSessionEntryKind::Assistant => { + runtime_api::SessionPublicEntryKind::Assistant + } + server_api::SubjektivSessionEntryKind::Tool => runtime_api::SessionPublicEntryKind::Tool, + } +} + +fn subjektiv_session_tool_part( + part: runtime_api::SessionPublicToolPart, +) -> server_api::SubjektivSessionToolPart { + match part { + runtime_api::SessionPublicToolPart::Input => server_api::SubjektivSessionToolPart::Input, + runtime_api::SessionPublicToolPart::Output => server_api::SubjektivSessionToolPart::Output, + runtime_api::SessionPublicToolPart::Both => server_api::SubjektivSessionToolPart::Both, + } +} + +fn runtime_session_tool_part( + part: server_api::SubjektivSessionToolPart, +) -> runtime_api::SessionPublicToolPart { + match part { + server_api::SubjektivSessionToolPart::Input => runtime_api::SessionPublicToolPart::Input, + server_api::SubjektivSessionToolPart::Output => runtime_api::SessionPublicToolPart::Output, + server_api::SubjektivSessionToolPart::Both => runtime_api::SessionPublicToolPart::Both, + } +} + +fn subjektiv_session_read_mode( + mode: server_api::SubjektivSessionReadMode, +) -> runtime_api::SessionPublicReadMode { + match mode { + server_api::SubjektivSessionReadMode::Compact => { + runtime_api::SessionPublicReadMode::Compact + } + server_api::SubjektivSessionReadMode::Full => runtime_api::SessionPublicReadMode::Full, + } +} + +fn subjektiv_session_lineage( + lineage: runtime_api::SessionPublicLineage, +) -> server_api::SubjektivSessionLineage { + server_api::SubjektivSessionLineage { + kind: match lineage.kind { + runtime_api::SessionPublicLineageKind::Root => { + server_api::SubjektivSessionLineageKind::Root + } + runtime_api::SessionPublicLineageKind::Fork => { + server_api::SubjektivSessionLineageKind::Fork + } + runtime_api::SessionPublicLineageKind::Compact => { + server_api::SubjektivSessionLineageKind::Compact + } + }, + parent_segment_id: lineage.parent_segment_id, + at_turn_index: lineage.at_turn_index, + } +} + +fn truncate_subjektiv_session_snippet(value: &str) -> (String, bool) { + if value.len() <= server_api::SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES { + (value.to_string(), false) + } else { + ( + truncate_subjektiv_session_utf8(value, server_api::SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES), + true, + ) + } +} + const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; @@ -18362,7 +19639,12 @@ fn decode_subjektiv_cursor( let encoded = cursor .strip_prefix(&prefix) .ok_or_else(|| Error::InvalidInput(format!("invalid subjektiv {kind} cursor type")))?; - if encoded.is_empty() || encoded.len() > 16_384 || encoded.len() % 2 != 0 { + if encoded.is_empty() + || encoded.len() > 16_384 + || encoded.len() % 2 != 0 + || !encoded.is_ascii() + || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) + { return Err(Error::InvalidInput(format!("invalid subjektiv {kind} cursor")).into()); } let mut raw = Vec::with_capacity(encoded.len() / 2); @@ -36038,6 +37320,39 @@ mod tests { ); } + #[test] + fn subjektiv_session_cursor_is_bounded_and_rejects_non_ascii_payloads() { + let cursor = SubjektivSessionListCursor { + workspace_id: "workspace-a".to_string(), + subject_id: "subject-a".to_string(), + controller_runtime_id: "runtime-a".to_string(), + controller_worker_id: worker_runtime::identity::WorkerId::now_v7().to_string(), + session_id: Some("session-a".to_string()), + storage: server_api::SubjektivSessionStorageFilter::Retained, + snapshot_at: "2026-10-02T00:00:00Z".to_string(), + after_attributed_at: "2026-10-01T00:00:00Z".to_string(), + after_session_id: "session-a".to_string(), + }; + let encoded = encode_subjektiv_cursor("session-list", &cursor).unwrap(); + let decoded: SubjektivSessionListCursor = + decode_subjektiv_session_cursor("session-list", &encoded).unwrap(); + assert_eq!(decoded.workspace_id, cursor.workspace_id); + assert_eq!(decoded.subject_id, cursor.subject_id); + assert_eq!(decoded.controller_worker_id, cursor.controller_worker_id); + assert!(matches!( + decode_subjektiv_session_cursor::( + "session-list", + "subjektiv.session-list.éé" + ), + Err(server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::StaleCursor, + .. + } + }) + )); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); @@ -36162,6 +37477,114 @@ mod tests { assert_eq!(attribution.subject_id, subject.id); assert_eq!(attribution.worker_id, created.worker_id); + let Json(ungranted) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::List( + server_api::SubjektivSessionListRequest { + session_id: None, + storage: server_api::SubjektivSessionStorageFilter::All, + limit: None, + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + ungranted, + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { ref items, .. } + ) + } if items.is_empty() + )); + + let worker = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id); + api.store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + grant_id: "subjektiv-session-self-observe".to_string(), + controller: worker.clone(), + subject: worker, + relation: "subject_session".to_string(), + origin: "test".to_string(), + permissions: vec!["observe".to_string()], + operation_id: "subjektiv-session-self-observe".to_string(), + created_at: Utc::now().to_rfc3339(), + revoked_at: None, + }) + .unwrap(); + let Json(granted) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::List( + server_api::SubjektivSessionListRequest { + session_id: Some("session-1".to_string()), + storage: server_api::SubjektivSessionStorageFilter::Retained, + limit: Some(1), + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + granted, + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { ref items, has_more: false, .. } + ) + } if items.len() == 1 + && items[0].session_id == "session-1" + && items[0].storage == server_api::SubjektivSessionStorage::Retained + && items[0].availability == server_api::SubjektivSessionAvailability::Unchecked + )); + let Json(unavailable_search) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::Search( + server_api::SubjektivSessionSearchRequest { + query: None, + session_id: Some("session-1".to_string()), + kind: None, + tool_name: None, + tool_part: server_api::SubjektivSessionToolPart::Both, + limit: None, + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!( + matches!( + &unavailable_search, + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::RetentionMissing, + .. + } + } + ), + "unexpected search response: {unavailable_search:?}" + ); + let target = store .create_memory( &subject.id, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index e0e182ee7..5cb03fd1b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 75; +const LATEST_SCHEMA_VERSION: i64 = 76; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str = "ordered Worker projection subscriptions without revision reconciliation"; const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str = "Runtime-scoped durable Worker catalog identity"; +const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str = + "preserve explicit observe grants for committed Worker Session archives"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[ name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME, apply: migrate_runtime_scoped_worker_identity_v74_to_v75, }, + Migration { + version: 76, + name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME, + apply: migrate_archive_observe_grants_v75_to_v76, + }, ]; #[derive(Clone, Copy)] @@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul } } +fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 75 { + return Err(Error::Store(format!( + "expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, + archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY ( + workspace_id, archive_id, + controller_runtime_id, controller_worker_id, + source_grant_id + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id, controller_runtime_id, controller_worker_id, archive_id + ); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -16617,6 +16665,10 @@ mod tests { version: 75, name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 76, + name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(), + }, ] ); @@ -16699,6 +16751,7 @@ mod tests { 75, RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), ), + (76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16985,7 +17038,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75 + 73, 74, 75, 76 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -16994,7 +17047,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 749ea31b1..767f6bef4 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -175,6 +175,12 @@ impl SubjectSessionAttribution { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectSessionAttributionPage { + pub items: Vec, + pub has_more: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RevisionProposalIntent { @@ -1208,6 +1214,93 @@ impl SubjektivStore { }) } + /// Reads one bounded immutable attribution page without materializing the + /// subject's complete history. `snapshot_at` fixes the initial discovery + /// boundary; later Host attributions require a fresh traversal. + pub fn subject_session_attribution_page( + &self, + subject_id: &str, + session_id: Option<&str>, + snapshot_at: &str, + after: Option<(&str, &str)>, + limit: usize, + ) -> Result { + validate_label("subject id", subject_id)?; + validate_label("attribution snapshot", snapshot_at)?; + if let Some(session_id) = session_id { + validate_label("session id", session_id)?; + } + if let Some((attributed_at, session_id)) = after { + validate_label("attribution cursor timestamp", attributed_at)?; + validate_label("attribution cursor session id", session_id)?; + } + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject Session attribution page limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let raw = match (session_id, after) { + (Some(session_id), _) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?4", + )?; + let rows = statement.query_map( + params![subject_id, session_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, Some((after_at, after_session_id))) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4)) + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?5", + )?; + let rows = statement.query_map( + params![ + subject_id, + snapshot_at, + after_at, + after_session_id, + query_limit as i64 + ], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, None) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?3", + )?; + let rows = statement.query_map( + params![subject_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + }; + let mut items = raw + .into_iter() + .map(|raw| parse_session_attribution(&raw)) + .collect::>>()?; + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectSessionAttributionPage { items, has_more }) + }) + } + pub fn staging_candidate( &self, subject_id: &str, @@ -3678,6 +3771,88 @@ mod tests { assert!(store.session_attribution("session-2").unwrap().is_none()); } + #[test] + fn attribution_pages_are_bounded_descending_and_snapshot_fixed() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + for (session, at) in [ + ("session-1", "2026-09-28T10:00:00.000Z"), + ("session-2", "2026-09-28T11:00:00.000Z"), + ("session-3", "2026-09-28T11:00:00.000Z"), + ] { + store + .record_session_attribution(attribution( + &subject.id, + "runtime-1", + "worker-1", + session, + at, + )) + .unwrap(); + } + + let first = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + None, + 2, + ) + .unwrap(); + assert_eq!( + first + .items + .iter() + .map(|item| item.session_id.as_str()) + .collect::>(), + vec!["session-2", "session-3"] + ); + assert!(first.has_more); + + store + .record_session_attribution(attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-later", + "2026-09-28T12:00:00.000Z", + )) + .unwrap(); + let last = first.items.last().unwrap(); + let second = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + Some((&last.attributed_at, &last.session_id)), + 2, + ) + .unwrap(); + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].session_id, "session-1"); + assert!(!second.has_more); + assert!( + second + .items + .iter() + .all(|item| item.session_id != "session-later") + ); + + let exact = store + .subject_session_attribution_page( + &subject.id, + Some("session-1"), + "2026-09-28T11:30:00.000Z", + None, + 20, + ) + .unwrap(); + assert_eq!(exact.items.len(), 1); + assert!(!exact.has_more); + } + #[test] fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index cfe89abdd..a19f095a4 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_registry_projection_removals", "worker_removal_operations", "worker_retention_audit_events", + "worker_session_archive_observe_grants", "worker_session_archives", "worker_singleton_owners", "worker_tombstones", diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 44a0a5554..bd0c77815 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -218,6 +218,46 @@ and exact backend retries return the first candidate rather than creating anothe one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`, or a surface snapshot. + +## Historical Session discovery (T-673) + +An enabled `feature.subjektiv` also installs exactly three read-only tools: +`SubjektivSessionList`, `SubjektivSessionSearch`, and `SubjektivSessionRead`. +The Server binds subject, Workspace, Runtime, Worker, retention catalogue, and +archive authority from the authenticated caller. Model input contains only +Session/segment/entry selectors, search filters, limits, and opaque cursors; +knowing any identifier does not grant access. Reads use Yoi's retained or +committed-archive Session storage and a shared public projection. They do not +copy Session bodies into the subjektiv database, restore Workers, migrate +Session storage, create candidates or Memories, revise a surface, or promote a +re-read tool result to HumanInput. + +The public projection covers committed records in every persisted segment, +including non-active branches and pre-compaction entries, while excluding +system prompts, hidden reasoning, traces/diagnostics, unfinished run tails, and +attachment or pasted-artifact bodies. Results preserve exact Session, segment, +entry, provenance, and lineage identity so inherited entries are not presented +as a current decision. Full entry reads are UTF-8-boundary paged and remain +subject to the same projection as snippets and search. + +The subject's first committed primary-Worker Session is attributed independently +of extraction thresholds or candidate creation. Recording is idempotent and is +retried on later committed-run and pre-request lifecycle points after transient +failure; no attribution is attempted before a committed capture exists. This is +not a distributed transaction with Session persistence. In particular, the +system does **not** backfill older unattributed Sessions from their text, display +name, current singleton ownership, Memory references, or other inference. +Historical Sessions without trustworthy Host-recorded attribution remain outside +subject discovery until a separately authorized provenance mechanism exists. + +Pagination cursors bind scope, operation, filters, stable ordering, and the +public storage generation and are never authorization credentials. Every page +rechecks current subject and observation authority. Deleted-Worker archives are +eligible only when both the committed Server catalogue and Runtime manifest +agree and the archived observation grant remains valid; incomplete, expired, +corrupt, unavailable, or changed sources are reported explicitly rather than as +an empty successful search. + Revision proposals add optional `revision_proposal` metadata to the existing v2 `SubjectStagingRecord` envelope; automatic extraction remains proposal-free and its model schema is unchanged. The metadata is serialized atomically with the diff --git a/openapi/server-api.json b/openapi/server-api.json index 968d40110..a024f5f80 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2832,6 +2832,19 @@ ], "type": "object" }, + { + "$ref": "#/components/schemas/MemoryResidentSummaryOutput", + "properties": { + "kind": { + "const": "resident_summary", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, { "$ref": "#/components/schemas/MemoryBackendAckOutput", "properties": { @@ -3084,9 +3097,36 @@ }, "type": "object" }, + "MemoryResidentSummaryAvailability": { + "enum": [ + "ready", + "ungenerated", + "stale", + "failed", + "unavailable" + ], + "type": "string" + }, "MemoryResidentSummaryOperation": { "type": "object" }, + "MemoryResidentSummaryOutput": { + "properties": { + "availability": { + "$ref": "#/components/schemas/MemoryResidentSummaryAvailability" + }, + "content": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "availability" + ], + "type": "object" + }, "MemorySettingsAudit": { "properties": { "language": { @@ -14081,7 +14121,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9" + "x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 7e782b48b..5f899c88a 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 3ca4ddb28..59b5e68a2 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 79b0d1bf0..9f33fc5dd 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index e687625f0..a1d54d8ed 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 0824a0267..f0cec690a 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check From c491cf8a48653b75e98f03468e8b5641c2ebba16 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 17:36:10 +0900 Subject: [PATCH 27/31] fix: make subject session reads resumable --- crates/runtime-api/src/lib.rs | 9 +- crates/session-store/src/lib.rs | 5 +- crates/session-store/src/public_index.rs | 649 +++++++++++++++++- .../session-store/src/worker_session_store.rs | 16 + crates/worker-runtime/src/session_public.rs | 288 ++++++-- .../src/feature/builtin/subjektiv_session.rs | 38 + crates/workspace-server/src/latest_schema.sql | 2 +- crates/workspace-server/src/retention.rs | 52 ++ crates/workspace-server/src/server.rs | 569 ++++++++++----- crates/workspace-server/src/store.rs | 15 +- 10 files changed, 1410 insertions(+), 233 deletions(-) diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 7eac0c840..a10b33361 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -755,7 +755,8 @@ pub struct SessionPublicSearchRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_name: Option, pub tool_part: SessionPublicToolPart, - pub offset: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scan_cursor: Option, pub limit: usize, pub max_scan_bytes: u64, pub max_segments: usize, @@ -789,7 +790,10 @@ pub struct SessionPublicSearchItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_part: Option, pub compact: String, + pub compact_truncated: bool, pub lineage: SessionPublicLineage, + /// Opaque Runtime continuation immediately before this result. + pub scan_cursor: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -797,7 +801,8 @@ pub struct SessionPublicSearchPage { pub session_id: String, pub generation: String, pub items: Vec, - pub next_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_scan_cursor: Option, pub has_more: bool, pub scanned_bytes: u64, pub scanned_segments: usize, diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index a3f9a4207..0a16e0451 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -101,8 +101,9 @@ pub use public_index::{ DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, - SessionPublicIndexReadError, SessionPublicIndexSegment, SessionPublicIndexToolPart, - read_session_public_index, + SessionPublicIndexPage, SessionPublicIndexPageEntry, SessionPublicIndexReadError, + SessionPublicIndexScanPosition, SessionPublicIndexSegment, SessionPublicIndexToolPart, + read_session_public_index, read_session_public_index_page, }; pub use public_snapshot::{ DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index 5ed7693f5..b5176e1df 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +use crate::worker_session_store::RetainedLogRecord; use crate::{LogEntry, SegmentId, SegmentOrigin, SessionId, StoreError, WorkerSessionStore}; pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES: u64 = 64 * 1024 * 1024; @@ -50,6 +51,48 @@ pub enum SessionPublicIndexReadError { Storage, #[error("Worker Session public index exceeds a resource limit")] ResourceLimit, + #[error("Worker Session public index cursor is stale")] + StaleCursor, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexScanPosition { + pub segment_index: usize, + pub byte_offset: u64, + pub entry_offset: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lineage: Option, +} + +impl Default for SessionPublicIndexScanPosition { + fn default() -> Self { + Self { + segment_index: 0, + byte_offset: 0, + entry_offset: 0, + lineage: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPublicIndexPageEntry { + pub segment_id: String, + pub lineage: SessionPublicIndexLineage, + pub entry: SessionPublicIndexEntry, + pub scan_from: SessionPublicIndexScanPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPublicIndexPage { + pub session_id: String, + pub generation: String, + pub entries: Vec, + pub next_position: Option, + pub has_more: bool, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -121,6 +164,7 @@ pub struct SessionPublicIndexEntry { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_name: Option, pub compact_text: String, + pub compact_text_truncated: bool, pub full_text: String, } @@ -246,6 +290,421 @@ pub fn read_session_public_index( }) } +/// Incrementally scan the canonical public Session index. +/// +/// Continuations stop only at a retained log-unit boundary. Ordinary records are +/// independent units; a run from `Invoke`/`RunResumed` through its terminal +/// record remains one unit so uncommitted output never crosses the public +/// projection. A single unit that cannot fit the caller's byte or entry budget +/// is therefore the only scan shape that returns [`SessionPublicIndexReadError::ResourceLimit`]. +pub fn read_session_public_index_page( + session_root: &Path, + limits: SessionPublicIndexLimits, + expected_generation: Option<&str>, + position: Option, + max_results: usize, + mut matches: F, +) -> Result +where + F: FnMut(&SessionPublicIndexEntry) -> bool, +{ + if limits.max_bytes == 0 + || limits.max_segments == 0 + || limits.max_entries == 0 + || max_results == 0 + { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let store = WorkerSessionStore::open_read_only(session_root).map_err(map_store_open_error)?; + let session_id = store + .session_id() + .map_err(map_store_error)? + .ok_or(SessionPublicIndexReadError::Missing)?; + let segment_ids = store + .list_segments_read_only(session_id) + .map_err(map_store_error)?; + let generation = scan_generation(&store, session_id, &segment_ids)?; + if expected_generation.is_some_and(|expected| expected != generation) { + return Err(SessionPublicIndexReadError::StaleCursor); + } + + let mut position = position.unwrap_or_default(); + if position.segment_index > segment_ids.len() + || (position.segment_index == segment_ids.len() + && (position.byte_offset != 0 + || position.entry_offset != 0 + || position.lineage.is_some())) + { + return Err(SessionPublicIndexReadError::StaleCursor); + } + + let mut scanned_bytes = 0_u64; + let mut scanned_segments = 0_usize; + let mut scanned_entries = 0_usize; + let mut output = Vec::new(); + let mut made_progress = false; + let mut stopped = false; + + while position.segment_index < segment_ids.len() && output.len() < max_results { + if scanned_segments == limits.max_segments { + stopped = true; + break; + } + let segment_id = segment_ids[position.segment_index]; + scanned_segments += 1; + + let (file_len, lineage, mut first_for_unit) = if position.byte_offset == 0 { + let remaining = limits.max_bytes.saturating_sub(scanned_bytes); + let (first, first_bytes, file_len) = match store + .read_first_log_record_read_only_bounded(session_id, segment_id, remaining) + { + Ok(value) => value, + Err(StoreError::ReadLimitExceeded) if made_progress => { + stopped = true; + break; + } + Err(error) => return Err(map_store_error(error)), + }; + let Some(first) = first else { + return Err(SessionPublicIndexReadError::Corrupt); + }; + scanned_bytes = scanned_bytes + .checked_add(first_bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + let (lineage, origin) = + validate_segment_start(session_id, segment_id, std::slice::from_ref(&first.entry))?; + if position + .lineage + .as_ref() + .is_some_and(|expected| expected != &lineage) + { + return Err(SessionPublicIndexReadError::StaleCursor); + } + validate_incremental_origin(segment_id, origin.as_ref(), &segment_ids)?; + (file_len, lineage, Some(first)) + } else { + let lineage = position + .lineage + .clone() + .ok_or(SessionPublicIndexReadError::StaleCursor)?; + validate_incremental_lineage(segment_id, &lineage, &segment_ids)?; + let (file_len, _) = store + .segment_log_observation(segment_id) + .map_err(map_store_error)?; + (file_len, lineage, None) + }; + + if position.byte_offset > file_len + || (position.byte_offset == file_len && position.entry_offset != 0) + { + return Err(SessionPublicIndexReadError::StaleCursor); + } + if position.byte_offset == file_len { + position = next_segment_position(position.segment_index); + made_progress = true; + continue; + } + loop { + if position.byte_offset >= file_len || output.len() == max_results { + break; + } + let unit_start = position.byte_offset; + let budget_before_unit = (scanned_bytes, scanned_entries); + let unit = match read_public_unit( + &store, + session_id, + segment_id, + unit_start, + limits.max_bytes.saturating_sub(scanned_bytes), + first_for_unit.take(), + ) { + Ok(unit) => unit, + Err(SessionPublicIndexReadError::ResourceLimit) if made_progress => { + scanned_bytes = budget_before_unit.0; + scanned_entries = budget_before_unit.1; + stopped = true; + break; + } + Err(error) => return Err(error), + }; + if unit_start > 0 + && unit + .entries + .iter() + .any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. })) + { + return Err(SessionPublicIndexReadError::Corrupt); + } + let unit_entries = persisted_entry_units(&unit.entries); + if scanned_entries.saturating_add(unit_entries) > limits.max_entries { + if made_progress { + stopped = true; + break; + } + return Err(SessionPublicIndexReadError::ResourceLimit); + } + scanned_bytes = scanned_bytes + .checked_add(unit.bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + scanned_entries = scanned_entries + .checked_add(unit_entries) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + + let committed = committed_conversation_records(&unit.entries); + let snapshot = crate::public_snapshot::project_session_snapshot_for_segment( + session_id, + Some(segment_id), + &committed, + ); + let projected = project_index_entries(segment_id, snapshot.entries)?; + if position.entry_offset > projected.len() + || (position.entry_offset != 0 && position.entry_offset == projected.len()) + { + return Err(SessionPublicIndexReadError::StaleCursor); + } + for (entry_index, entry) in projected + .into_iter() + .enumerate() + .skip(position.entry_offset) + { + let resume_at = if entry_index + 1 < unit.public_entry_count { + SessionPublicIndexScanPosition { + segment_index: position.segment_index, + byte_offset: unit_start, + entry_offset: entry_index + 1, + lineage: Some(lineage.clone()), + } + } else if unit.end_offset == file_len { + next_segment_position(position.segment_index) + } else { + SessionPublicIndexScanPosition { + segment_index: position.segment_index, + byte_offset: unit.end_offset, + entry_offset: 0, + lineage: Some(lineage.clone()), + } + }; + if matches(&entry) { + output.push(SessionPublicIndexPageEntry { + segment_id: segment_id.to_string(), + lineage: lineage.clone(), + entry, + scan_from: SessionPublicIndexScanPosition { + segment_index: position.segment_index, + byte_offset: unit_start, + entry_offset: entry_index, + lineage: Some(lineage.clone()), + }, + }); + if output.len() == max_results { + position = resume_at; + break; + } + } + } + made_progress = true; + if output.len() == max_results { + break; + } + position = if unit.end_offset == file_len { + next_segment_position(position.segment_index) + } else { + SessionPublicIndexScanPosition { + segment_index: position.segment_index, + byte_offset: unit.end_offset, + entry_offset: 0, + lineage: Some(lineage.clone()), + } + }; + if position.segment_index >= segment_ids.len() + || position.segment_index + != segment_ids + .iter() + .position(|candidate| *candidate == segment_id) + .unwrap_or(position.segment_index) + { + break; + } + } + if stopped || output.len() == max_results { + break; + } + if position.segment_index < segment_ids.len() + && segment_ids[position.segment_index] == segment_id + { + position = next_segment_position(position.segment_index); + } + } + + let final_segment_ids = store + .list_segments_read_only(session_id) + .map_err(map_store_error)?; + let final_generation = scan_generation(&store, session_id, &final_segment_ids)?; + if final_generation != generation { + return Err(SessionPublicIndexReadError::StaleCursor); + } + + let has_more = stopped || position.segment_index < segment_ids.len(); + Ok(SessionPublicIndexPage { + session_id: session_id.to_string(), + generation, + entries: output, + next_position: has_more.then_some(position), + has_more, + scanned_bytes, + scanned_segments, + scanned_entries, + }) +} + +#[derive(Debug)] +struct PublicLogUnit { + entries: Vec, + end_offset: u64, + bytes: u64, + public_entry_count: usize, +} + +fn read_public_unit( + store: &WorkerSessionStore, + session_id: SessionId, + segment_id: SegmentId, + start_offset: u64, + max_bytes: u64, + first: Option, +) -> Result { + let (first, first_bytes) = match first { + Some(record) => (Some(record), 0), + None => store + .read_next_log_record_read_only_bounded(session_id, segment_id, start_offset, max_bytes) + .map_err(map_store_error)?, + }; + let Some(first) = first else { + return Err(SessionPublicIndexReadError::StaleCursor); + }; + if first.start_offset != start_offset { + return Err(SessionPublicIndexReadError::StaleCursor); + } + let mut bytes = first_bytes; + let mut end_offset = first.end_offset; + let run = matches!( + first.entry, + LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } + ); + let mut entries = vec![first.entry]; + if run { + loop { + let (next, read_bytes) = store + .read_next_log_record_read_only_bounded( + session_id, + segment_id, + end_offset, + max_bytes.saturating_sub(bytes), + ) + .map_err(map_store_error)?; + let Some(next) = next else { + break; + }; + bytes = bytes + .checked_add(read_bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + end_offset = next.end_offset; + let terminal = matches!( + next.entry, + LogEntry::RunCompleted { .. } + | LogEntry::RunYielded { .. } + | LogEntry::RunCancelled { .. } + | LogEntry::RunErrored { .. } + | LogEntry::PausedTurnAbandoned { .. } + ); + entries.push(next.entry); + if terminal { + break; + } + } + } + let committed = committed_conversation_records(&entries); + let snapshot = crate::public_snapshot::project_session_snapshot_for_segment( + session_id, + Some(segment_id), + &committed, + ); + let public_entry_count = project_index_entries(segment_id, snapshot.entries)?.len(); + Ok(PublicLogUnit { + entries, + end_offset, + bytes, + public_entry_count, + }) +} + +fn next_segment_position(segment_index: usize) -> SessionPublicIndexScanPosition { + SessionPublicIndexScanPosition { + segment_index: segment_index.saturating_add(1), + byte_offset: 0, + entry_offset: 0, + lineage: None, + } +} + +fn validate_incremental_lineage( + segment_id: SegmentId, + lineage: &SessionPublicIndexLineage, + segment_ids: &[SegmentId], +) -> Result<(), SessionPublicIndexReadError> { + let parent = match lineage.origin_kind { + SessionPublicIndexOriginKind::Root => { + if lineage.parent_segment_id.is_some() || lineage.parent_turn_index.is_some() { + return Err(SessionPublicIndexReadError::StaleCursor); + } + return Ok(()); + } + SessionPublicIndexOriginKind::Fork | SessionPublicIndexOriginKind::Compact => lineage + .parent_segment_id + .as_deref() + .and_then(|value| uuid::Uuid::parse_str(value).ok()) + .ok_or(SessionPublicIndexReadError::StaleCursor)?, + }; + if parent == segment_id || !segment_ids.contains(&parent) || lineage.parent_turn_index.is_none() + { + return Err(SessionPublicIndexReadError::StaleCursor); + } + Ok(()) +} + +fn validate_incremental_origin( + segment_id: SegmentId, + origin: Option<&SegmentOrigin>, + segment_ids: &[SegmentId], +) -> Result<(), SessionPublicIndexReadError> { + if origin.is_some_and(|origin| { + origin.segment_id == segment_id || !segment_ids.contains(&origin.segment_id) + }) { + return Err(SessionPublicIndexReadError::Corrupt); + } + Ok(()) +} + +fn scan_generation( + store: &WorkerSessionStore, + session_id: SessionId, + segment_ids: &[SegmentId], +) -> Result { + let mut generation = Sha256::new(); + generation.update(b"yoi-session-public-index-scan-v1\0"); + generation.update(session_id.as_bytes()); + for segment_id in segment_ids { + let (len, modified_nanos) = store + .segment_log_observation(*segment_id) + .map_err(map_store_error)?; + generation.update(segment_id.as_bytes()); + generation.update(len.to_be_bytes()); + generation.update(modified_nanos.to_be_bytes()); + } + Ok(URL_SAFE_NO_PAD.encode(generation.finalize())) +} + fn validate_segment_start( expected_session_id: SessionId, segment_id: SegmentId, @@ -457,6 +916,7 @@ fn project_index_entries( _ => continue, }; + let (compact_text, compact_text_truncated) = bounded_compact_text(&compact_source); projected.push(SessionPublicIndexEntry { segment_id: segment_id.to_string(), entry_ref, @@ -465,10 +925,8 @@ fn project_index_entries( timestamp: entry.timestamp, tool_part, tool_name, - compact_text: truncate_utf8( - &compact_source, - SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, - ), + compact_text, + compact_text_truncated, full_text, }); } @@ -476,6 +934,13 @@ fn project_index_entries( Ok(projected) } +fn bounded_compact_text(value: &str) -> (String, bool) { + ( + truncate_utf8(value, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES), + value.len() > SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, + ) +} + fn content_parts_text(parts: &[SessionContentPart]) -> String { let mut output = String::new(); for part in parts { @@ -855,6 +1320,19 @@ mod tests { } } + #[test] + fn compact_text_reports_utf8_safe_truncation() { + let exact = "x".repeat(SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES); + assert_eq!(bounded_compact_text(&exact), (exact, false)); + + let oversized = "界".repeat(SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES); + let (compact, truncated) = bounded_compact_text(&oversized); + assert!(truncated); + assert!(compact.len() <= SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES); + assert!(compact.ends_with('…')); + assert!(compact.is_char_boundary(compact.len())); + } + #[test] fn excludes_uncommitted_cancelled_and_failed_outputs_but_keeps_committed_outcomes() { let (_temp, root, store) = create_store(); @@ -1079,6 +1557,169 @@ mod tests { assert_ne!(replaced.generation, added.generation); } + #[test] + fn incremental_scan_finds_hit_beyond_segment_budget_and_stales_after_append() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + for index in 0..65_u128 { + let text = if index == 64 { + "needle beyond first page" + } else { + "ordinary entry" + }; + store + .create_segment( + session_id, + segment_id(1_000 + index), + &[start( + session_id, + vec![message(&format!("entry-{index}"), LoggedRole::User, text)], + None, + None, + )], + ) + .unwrap(); + } + let limits = SessionPublicIndexLimits { + max_segments: 64, + ..SessionPublicIndexLimits::default() + }; + let first = read_session_public_index_page(&root, limits, None, None, 10, |entry| { + entry.full_text.contains("needle") + }) + .unwrap(); + assert!(first.entries.is_empty()); + assert!(first.has_more); + assert_eq!(first.scanned_segments, 64); + let next_position = first.next_position.clone().unwrap(); + + let second = read_session_public_index_page( + &root, + limits, + Some(&first.generation), + Some(next_position.clone()), + 10, + |entry| entry.full_text.contains("needle"), + ) + .unwrap(); + assert_eq!(second.entries.len(), 1); + assert_eq!( + second.entries[0].entry.full_text, + "needle beyond first page" + ); + assert!(!second.has_more); + assert!(second.next_position.is_none()); + + store + .append( + session_id, + segment_id(1_064), + &LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("mutated", LoggedRole::Assistant, "changed"), + }, + ) + .unwrap(); + assert_eq!( + read_session_public_index_page( + &root, + limits, + Some(&first.generation), + Some(next_position), + 10, + |_| true, + ), + Err(SessionPublicIndexReadError::StaleCursor) + ); + } + + #[test] + fn incremental_scan_resumes_across_byte_and_entry_budgets() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let first_segment = segment_id(1_500); + let second_segment = segment_id(1_501); + for (segment_id, id, text) in [ + (first_segment, "entry-a", "aaaaaa"), + (second_segment, "entry-b", "needle"), + ] { + store + .create_segment( + session_id, + segment_id, + &[start( + session_id, + vec![message(id, LoggedRole::User, text)], + None, + None, + )], + ) + .unwrap(); + } + let first_len = store.segment_log_len(first_segment).unwrap(); + for limits in [ + SessionPublicIndexLimits { + max_bytes: first_len, + ..SessionPublicIndexLimits::default() + }, + SessionPublicIndexLimits { + max_entries: 2, + ..SessionPublicIndexLimits::default() + }, + ] { + let first = read_session_public_index_page(&root, limits, None, None, 10, |entry| { + entry.full_text == "needle" + }) + .unwrap(); + assert!(first.entries.is_empty()); + assert!(first.has_more); + let second = read_session_public_index_page( + &root, + limits, + Some(&first.generation), + first.next_position, + 10, + |entry| entry.full_text == "needle", + ) + .unwrap(); + assert_eq!(second.entries.len(), 1); + assert!(!second.has_more); + } + } + + #[test] + fn incremental_scan_rejects_one_indivisible_record_over_the_byte_budget() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + store + .create_segment( + session_id, + segment_id(2_000), + &[start( + session_id, + vec![message("large-entry", LoggedRole::User, &"x".repeat(8_192))], + None, + None, + )], + ) + .unwrap(); + + assert_eq!( + read_session_public_index_page( + &root, + SessionPublicIndexLimits { + max_bytes: 512, + ..SessionPublicIndexLimits::default() + }, + None, + None, + 10, + |_| true, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + } + #[test] fn enforces_byte_segment_and_entry_bounds_with_typed_errors() { let (_temp, root, store) = create_store(); diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index f6820cff1..3711524e5 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -485,6 +485,22 @@ impl WorkerSessionStore { }) } + pub(crate) fn segment_log_observation( + &self, + segment_id: SegmentId, + ) -> Result<(u64, u128), StoreError> { + let metadata = fs::metadata(self.log_path(segment_id))?; + let modified = metadata + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| StoreError::Corrupt { + line: 0, + message: "retained Segment modification time predates the Unix epoch".to_string(), + })? + .as_nanos(); + Ok((metadata.len(), modified)) + } + pub fn segment_log_len(&self, segment_id: SegmentId) -> Result { Ok(fs::metadata(self.log_path(segment_id))?.len()) } diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index 1ff39224c..c8a9c486d 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -1,5 +1,6 @@ use std::path::Path; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use runtime_api::{ SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind, SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage, @@ -7,14 +8,23 @@ use runtime_api::{ SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart, SessionPublicUnavailableReason, WorkerSessionArchiveManifest, }; +use serde::{Deserialize, Serialize}; use session_store::{ SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, - SessionPublicIndexReadError, SessionPublicIndexToolPart, read_session_public_index, + SessionPublicIndexReadError, SessionPublicIndexScanPosition, SessionPublicIndexToolPart, + read_session_public_index, read_session_public_index_page, }; use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionPublicRuntimeCursor { + generation: String, + position: SessionPublicIndexScanPosition, +} + pub(crate) fn search( session_root: &Path, request: &SessionPublicSearchRequest, @@ -26,29 +36,23 @@ pub(crate) fn search( "Session public search limit must be between 1 and 100", ); } - let index = match load_index( - session_root, - request.max_scan_bytes, - request.max_segments, - request.max_entries, - ) { - Ok(index) => index, - Err((reason, message)) => return unavailable(reason, message), + let cursor = match request.scan_cursor.as_deref().map(decode_scan_cursor) { + Some(Ok(cursor)) => Some(cursor), + Some(Err(())) => { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search cursor is invalid", + ); + } + None => None, }; - if index.session_id != request.expected_session_id { - return unavailable( - SessionPublicUnavailableReason::NotFound, - "Session identity does not match the authorized source", - ); - } - if request - .expected_generation - .as_deref() - .is_some_and(|expected| expected != index.generation) + if let (Some(expected), Some(cursor)) = + (request.expected_generation.as_deref(), cursor.as_ref()) + && expected != cursor.generation { return unavailable( SessionPublicUnavailableReason::InvalidCursor, - "Session public search generation changed", + "Session public search cursor generation changed", ); } @@ -58,51 +62,104 @@ pub(crate) fn search( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_lowercase); - let mut matches = Vec::new(); - for segment in &index.segments { - for entry in &segment.entries { - if !matches_entry(entry, request, query.as_deref()) { - continue; - } - matches.push(SessionPublicSearchItem { - segment_id: segment.segment_id.clone(), - entry_ref: entry.entry_ref.clone(), - kind: entry_kind(entry.kind), - origin: entry.provenance.clone(), - tool_name: entry.tool_name.clone(), - tool_part: entry.tool_part.map(tool_part), - compact: entry.compact_text.clone(), - lineage: lineage(&segment.lineage), - }); + let expected_generation = request + .expected_generation + .as_deref() + .or_else(|| cursor.as_ref().map(|cursor| cursor.generation.as_str())); + let index = match read_session_public_index_page( + session_root, + SessionPublicIndexLimits { + max_bytes: request.max_scan_bytes, + max_segments: request.max_segments, + max_entries: request.max_entries, + }, + expected_generation, + cursor.as_ref().map(|cursor| cursor.position.clone()), + request.limit, + |entry| matches_entry(entry, request, query.as_deref()), + ) { + Ok(index) => index, + Err(error) => { + let (reason, message) = map_index_error(error); + return unavailable(reason, message); } - } - if request.offset > matches.len() { + }; + if index.session_id != request.expected_session_id { return unavailable( - SessionPublicUnavailableReason::InvalidCursor, - "Session public search position is stale", + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", ); } - let end = request - .offset - .saturating_add(request.limit) - .min(matches.len()); - let items = matches[request.offset..end].to_vec(); - let has_more = end < matches.len(); + + let mut items = Vec::with_capacity(index.entries.len()); + for item in index.entries { + let scan_cursor = match encode_scan_cursor(&SessionPublicRuntimeCursor { + generation: index.generation.clone(), + position: item.scan_from, + }) { + Ok(cursor) => cursor, + Err(()) => { + return unavailable( + SessionPublicUnavailableReason::StorageUnavailable, + "Session public search cursor could not be encoded", + ); + } + }; + let entry = item.entry; + items.push(SessionPublicSearchItem { + segment_id: item.segment_id, + entry_ref: entry.entry_ref, + kind: entry_kind(entry.kind), + origin: entry.provenance, + tool_name: entry.tool_name, + tool_part: entry.tool_part.map(tool_part), + compact: entry.compact_text, + compact_truncated: entry.compact_text_truncated, + lineage: lineage(&item.lineage), + scan_cursor, + }); + } + let next_scan_cursor = match index.next_position { + Some(position) => match encode_scan_cursor(&SessionPublicRuntimeCursor { + generation: index.generation.clone(), + position, + }) { + Ok(cursor) => Some(cursor), + Err(()) => { + return unavailable( + SessionPublicUnavailableReason::StorageUnavailable, + "Session public search cursor could not be encoded", + ); + } + }, + None => None, + }; SessionPublicSearchAvailability::Page { page: SessionPublicSearchPage { session_id: index.session_id, generation: index.generation, items, - next_offset: has_more.then_some(end), - has_more, + next_scan_cursor, + has_more: index.has_more, scanned_bytes: index.scanned_bytes, - scanned_segments: index.segments.len(), + scanned_segments: index.scanned_segments, scanned_entries: index.scanned_entries, archive_manifest: archive_manifest.map(api_archive_manifest), }, } } +fn encode_scan_cursor(cursor: &SessionPublicRuntimeCursor) -> Result { + serde_json::to_vec(cursor) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|_| ()) +} + +fn decode_scan_cursor(cursor: &str) -> Result { + let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|_| ())?; + serde_json::from_slice(&bytes).map_err(|_| ()) +} + pub(crate) fn read( session_root: &Path, request: &SessionPublicReadRequest, @@ -309,6 +366,10 @@ fn map_index_error( SessionPublicUnavailableReason::ResourceLimit, "Session scan exceeds a resource limit", ), + SessionPublicIndexReadError::StaleCursor => ( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search cursor is stale", + ), } } @@ -355,3 +416,132 @@ fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiv input_fingerprint: manifest.input_fingerprint.clone(), } } + +#[cfg(test)] +mod tests { + use agen::llm_client::RequestConfig; + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store, WorkerSessionStore, + }; + + use super::*; + + fn message(id: &str, text: &str) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::User, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.to_string()), + origin: LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".to_string(), + }, + derivation: None, + }, + } + } + + fn request( + session_id: session_store::SessionId, + generation: Option, + scan_cursor: Option, + ) -> SessionPublicSearchRequest { + SessionPublicSearchRequest { + workspace_id: "workspace-1".to_string(), + source: runtime_api::SessionPublicSource::Retained { + worker_id: crate::identity::WorkerId::now_v7(), + }, + expected_session_id: session_id.to_string(), + expected_generation: generation, + query: Some("needle".to_string()), + kind: None, + tool_name: None, + tool_part: SessionPublicToolPart::Both, + scan_cursor, + limit: 20, + max_scan_bytes: 64 * 1024 * 1024, + max_segments: 64, + max_entries: 100_000, + } + } + + #[test] + fn search_continues_beyond_segment_budget_and_stales_after_mutation() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + let store = WorkerSessionStore::new(&root).unwrap(); + let session_id = session_store::new_session_id(); + let mut last_segment = None; + for index in 0..65_u128 { + let segment_id = uuid::Uuid::from_u128(10_000 + index); + last_segment = Some(segment_id); + store + .create_segment( + session_id, + segment_id, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history: vec![message( + &format!("entry-{index}"), + if index == 64 { "needle" } else { "ordinary" }, + )], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + } + + let first = match search(&root, &request(session_id, None, None), None) { + SessionPublicSearchAvailability::Page { page } => page, + other => panic!("unexpected first search result: {other:?}"), + }; + assert!(first.items.is_empty()); + assert!(first.has_more); + let cursor = first.next_scan_cursor.clone().unwrap(); + + let second = match search( + &root, + &request( + session_id, + Some(first.generation.clone()), + Some(cursor.clone()), + ), + None, + ) { + SessionPublicSearchAvailability::Page { page } => page, + other => panic!("unexpected continued search result: {other:?}"), + }; + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].compact, "needle"); + assert!(!second.has_more); + + store + .append( + session_id, + last_segment.unwrap(), + &LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("mutation", "changed"), + }, + ) + .unwrap(); + assert!(matches!( + search( + &root, + &request(session_id, Some(first.generation), Some(cursor)), + None, + ), + SessionPublicSearchAvailability::Unavailable { + reason: SessionPublicUnavailableReason::InvalidCursor, + .. + } + )); + } +} diff --git a/crates/worker/src/feature/builtin/subjektiv_session.rs b/crates/worker/src/feature/builtin/subjektiv_session.rs index d5f101477..3f9dfab14 100644 --- a/crates/worker/src/feature/builtin/subjektiv_session.rs +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -730,6 +730,44 @@ mod tests { } } )); + + let escaped_content = "\0".repeat(8 * 1024); + let paged_read = SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(server_api::SubjektivSessionReadResponse { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + entry_ref: "Eentry-1".into(), + kind: server_api::SubjektivSessionEntryKind::User, + origin: protocol::SessionEntryProvenance::HumanInput, + lineage: server_api::SubjektivSessionLineage { + kind: server_api::SubjektivSessionLineageKind::Root, + parent_segment_id: None, + at_turn_index: None, + }, + mode: server_api::SubjektivSessionReadMode::Full, + content: escaped_content.clone(), + truncated: true, + next_cursor: Some("next-page".into()), + has_more: true, + }), + }; + let output = model_visible_response(paged_read).unwrap(); + let content = output.content.unwrap(); + assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES); + let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read( + server_api::SubjektivSessionReadResponse { + content, + next_cursor: Some(cursor), + has_more: true, + .. + } + ) + } if content == escaped_content && cursor == "next-page" + )); } #[tokio::test] diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index c640193f9..3a24911fc 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -961,7 +961,7 @@ CREATE TABLE worker_session_archive_observe_grants ( workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL, controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL, subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL, - source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, + source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT, PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE); diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index cd726fa0f..bb36636f8 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -709,6 +709,7 @@ impl SqliteWorkspaceStore { WHERE workspace_id = ?1 AND archive_id = ?2 AND controller_runtime_id = ?3 AND controller_worker_id = ?4 AND subject_runtime_id = ?5 AND subject_worker_id = ?6 + AND revoked_at IS NULL )", params![ workspace_id, @@ -1301,6 +1302,32 @@ mod tests { #[test] fn purge_tombstone_commit_is_idempotent() { let s = setup(); + let archived_grant_id = "archive-observe-grant"; + let archived_controller = + RuntimeWorkerRef::new("r", WorkerId::from_legacy_u64(2).to_string()); + s.with_conn(|conn| { + conn.execute( + "INSERT INTO worker_registry(\ + workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at\ + ) VALUES('w',?1,'r','controller','builtin:companion','normal','created','controller-rev1')", + [&archived_controller.worker_id], + )?; + Ok(()) + }) + .unwrap(); + s.create_worker_control_grant(&crate::store::WorkerControlGrantRecord { + workspace_id: "w".into(), + grant_id: archived_grant_id.into(), + controller: archived_controller.clone(), + subject: req().worker, + relation: "subject_session".into(), + origin: "test".into(), + permissions: vec!["observe".into()], + operation_id: archived_grant_id.into(), + created_at: "2026-10-02T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); s.with_conn(|conn| { conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?; conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?; @@ -1351,6 +1378,31 @@ mod tests { .state, WorkerRemovalPlanState::Succeeded ); + assert!( + s.worker_session_archive_observable_by( + "w", + p.archive_id.as_deref().unwrap(), + &archived_controller, + &p.worker, + ) + .unwrap(), + "the explicit observe grant survives Worker removal for the committed archive" + ); + assert!( + s.revoke_worker_control_grant("w", archived_grant_id, "2026-10-02T00:01:00Z") + .unwrap(), + "the existing grant revocation authority must also revoke its archive projection" + ); + assert!( + !s.worker_session_archive_observable_by( + "w", + p.archive_id.as_deref().unwrap(), + &archived_controller, + &p.worker, + ) + .unwrap(), + "every later cursor page and direct Read must observe archive grant revocation" + ); assert!(s.worker_tombstone("w", &p.worker).unwrap().is_some()); let reservation_state: String = s.with_conn(|conn| { conn.query_row( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0858b92a8..4347072a0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -17033,7 +17033,7 @@ struct SubjektivSessionSearchPosition { storage: server_api::SubjektivSessionStorage, archive_id: Option, generation: String, - offset: usize, + runtime_scan_cursor: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17258,9 +17258,7 @@ fn subjektiv_session_list( } } - let has_more = session_id.is_none() - && (store_has_more || scanned == SUBJEKTIV_SESSION_MAX_LIMIT) - && after.is_some(); + let has_more = session_id.is_none() && store_has_more && after.is_some(); let next_cursor = if has_more { let (after_attributed_at, after_session_id) = after.expect("checked above"); match encode_subjektiv_cursor( @@ -17404,102 +17402,129 @@ fn subjektiv_session_read( "Session storage changed while the read cursor was active", ); } - let request = runtime_api::SessionPublicReadRequest { - workspace_id: workspace_id.to_string(), - source: source.source.clone(), - expected_session_id: session_id.clone(), - expected_generation: cursor.as_ref().map(|cursor| cursor.generation.clone()), - segment_id: segment_id.clone(), - entry_ref: entry_ref.clone(), - mode: subjektiv_session_read_mode(input.mode), - byte_offset: cursor.as_ref().map_or(0, |cursor| cursor.byte_offset), - max_content_bytes: server_api::SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES, - max_scan_bytes: SUBJEKTIV_SESSION_MAX_SCAN_BYTES, - max_segments: SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS, - max_entries: SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES, - }; - let availability = match api - .runtime - .session_public_read(&attribution.runtime_id, request) - { - Ok(availability) => availability, - Err(error) => return subjektiv_session_runtime_error(error), - }; - let page = match availability { - runtime_api::SessionPublicReadAvailability::Page { page } => page, - runtime_api::SessionPublicReadAvailability::Unavailable { reason, message } => { - return subjektiv_session_error(subjektiv_session_diagnostic_code(reason), message); + let expected_mode = subjektiv_session_read_mode(input.mode); + let byte_offset = cursor.as_ref().map_or(0, |cursor| cursor.byte_offset); + let mut expected_generation = cursor.as_ref().map(|cursor| cursor.generation.clone()); + let mut max_content_bytes = server_api::SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES; + loop { + let request = runtime_api::SessionPublicReadRequest { + workspace_id: workspace_id.to_string(), + source: source.source.clone(), + expected_session_id: session_id.clone(), + expected_generation: expected_generation.clone(), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: expected_mode, + byte_offset, + max_content_bytes, + max_scan_bytes: SUBJEKTIV_SESSION_MAX_SCAN_BYTES, + max_segments: SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS, + max_entries: SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES, + }; + let availability = match api + .runtime + .session_public_read(&attribution.runtime_id, request) + { + Ok(availability) => availability, + Err(error) => return subjektiv_session_runtime_error(error), + }; + let page = match availability { + runtime_api::SessionPublicReadAvailability::Page { page } => page, + runtime_api::SessionPublicReadAvailability::Unavailable { reason, message } => { + return subjektiv_session_error(subjektiv_session_diagnostic_code(reason), message); + } + }; + if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + "Runtime Session archive does not match the committed catalog", + ); } - }; - if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, - "Runtime Session archive does not match the committed catalog", - ); - } - if page.session_id != session_id - || page.segment_id != segment_id - || page.entry_ref != entry_ref - || page.mode != subjektiv_session_read_mode(input.mode) - { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::CorruptLog, - "Runtime Session read response did not match the selected entry", - ); - } - let next_cursor = if page.has_more { - let Some(byte_offset) = page.next_byte_offset else { + if page.session_id != session_id + || page.segment_id != segment_id + || page.entry_ref != entry_ref + || page.mode != expected_mode + { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::CorruptLog, - "Runtime Session read response omitted its continuation position", + "Runtime Session read response did not match the selected entry", ); + } + if expected_generation.is_none() { + expected_generation = Some(page.generation.clone()); + } + let next_cursor = if page.has_more { + let Some(next_byte_offset) = page.next_byte_offset else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session read response omitted its continuation position", + ); + }; + match encode_subjektiv_cursor( + "session-read", + &SubjektivSessionReadCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + session_id: session_id.clone(), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: input.mode, + runtime_id: attribution.runtime_id.clone(), + worker_id: attribution.worker_id.clone(), + storage: source.storage, + archive_id: source.archive_id.clone(), + generation: page.generation.clone(), + byte_offset: next_byte_offset, + }, + ) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ); + } + } + } else { + None }; - match encode_subjektiv_cursor( - "session-read", - &SubjektivSessionReadCursor { - workspace_id: workspace_id.to_string(), - subject_id: subject_id.to_string(), - controller_runtime_id: controller.runtime_id.clone(), - controller_worker_id: controller.worker_id.clone(), - session_id: session_id.clone(), - segment_id: segment_id.clone(), - entry_ref: entry_ref.clone(), - mode: input.mode, - runtime_id: attribution.runtime_id, - worker_id: attribution.worker_id, - storage: source.storage, - archive_id: source.archive_id, - generation: page.generation.clone(), - byte_offset, - }, - ) { - Ok(cursor) => Some(cursor), + let response = server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Read( + server_api::SubjektivSessionReadResponse { + session_id: session_id.clone(), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + kind: subjektiv_session_entry_kind(page.kind), + origin: page.origin, + lineage: subjektiv_session_lineage(page.lineage), + mode: input.mode, + content: page.content, + truncated: page.has_more, + next_cursor, + has_more: page.has_more, + }, + ), + }; + match subjektiv_session_response_fits_model_budget(&response) { + Ok(true) => return response, Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), + format!("Session read response could not be serialized: {error}"), + ); + } + Ok(_) if max_content_bytes > 4 => { + max_content_bytes = (max_content_bytes / 2).max(4); + } + Ok(_) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ResourceLimit, + "One Session read response exceeds the model-visible JSON budget", ); } } - } else { - None - }; - server_api::SubjektivSessionBackendResponse::Ok { - result: server_api::SubjektivSessionBackendResult::Read( - server_api::SubjektivSessionReadResponse { - session_id, - segment_id, - entry_ref, - kind: subjektiv_session_entry_kind(page.kind), - origin: page.origin, - lineage: subjektiv_session_lineage(page.lineage), - mode: input.mode, - content: page.content, - truncated: page.has_more, - next_cursor, - has_more: page.has_more, - }, - ), } } @@ -17609,87 +17634,88 @@ fn subjektiv_session_search( state.partial = true; break; } - let (attribution, expected_generation, offset, cursor_source) = if let Some(current) = - state.current.take() - { - let page = match store.subject_session_attribution_page( - subject_id, - Some(¤t.session_id), - &state.snapshot_at, - None, - 1, - ) { - Ok(page) => page, - Err(error) => { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.to_string(), - ); - } - }; - let Some(attribution) = page.items.into_iter().next() else { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search attribution is no longer available", - ); - }; - if attribution.attributed_at != current.attributed_at - || attribution.runtime_id != current.runtime_id - || attribution.worker_id != current.worker_id - { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search attribution changed", - ); - } - ( - attribution, - Some(current.generation), - current.offset, - Some((current.storage, current.archive_id)), - ) - } else { - let page = match store.subject_session_attribution_page( - subject_id, - state.session_id.as_deref(), - &state.snapshot_at, - match ( - state.after_attributed_at.as_deref(), - state.after_session_id.as_deref(), + let (attribution, expected_generation, runtime_scan_cursor, cursor_source) = + if let Some(current) = state.current.take() { + let page = match store.subject_session_attribution_page( + subject_id, + Some(¤t.session_id), + &state.snapshot_at, + None, + 1, ) { - (Some(attributed_at), Some(session_id)) => Some((attributed_at, session_id)), - (None, None) => None, - _ => { + Ok(page) => page, + Err(error) => { return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search cursor position is incomplete", + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), ); } - }, - 1, - ) { - Ok(page) => page, - Err(error) => { + }; + let Some(attribution) = page.items.into_iter().next() else { return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.to_string(), + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution is no longer available", + ); + }; + if attribution.attributed_at != current.attributed_at + || attribution.runtime_id != current.runtime_id + || attribution.worker_id != current.worker_id + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution changed", ); } + ( + attribution, + Some(current.generation), + Some(current.runtime_scan_cursor), + Some((current.storage, current.archive_id)), + ) + } else { + let page = match store.subject_session_attribution_page( + subject_id, + state.session_id.as_deref(), + &state.snapshot_at, + match ( + state.after_attributed_at.as_deref(), + state.after_session_id.as_deref(), + ) { + (Some(attributed_at), Some(session_id)) => { + Some((attributed_at, session_id)) + } + (None, None) => None, + _ => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor position is incomplete", + ); + } + }, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + if state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + has_more = page.has_more; + state.after_attributed_at = Some(attribution.attributed_at.clone()); + state.after_session_id = Some(attribution.session_id.clone()); + (attribution, None, None, None) }; - let Some(attribution) = page.items.into_iter().next() else { - if state.session_id.is_some() { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, - "Session was not found or is not authorized", - ); - } - break; - }; - has_more = page.has_more; - state.after_attributed_at = Some(attribution.attributed_at.clone()); - state.after_session_id = Some(attribution.session_id.clone()); - (attribution, None, 0, None) - }; scanned_sessions += 1; let source = match authorized_subjektiv_session( api, @@ -17744,7 +17770,7 @@ fn subjektiv_session_search( kind: input.kind.map(runtime_session_entry_kind), tool_name: tool_name.clone(), tool_part: runtime_session_tool_part(input.tool_part), - offset, + scan_cursor: runtime_scan_cursor, limit: limit.saturating_sub(items.len()), max_scan_bytes: remaining_scan_bytes, max_segments: remaining_segments, @@ -17806,7 +17832,9 @@ fn subjektiv_session_search( let generation = page.generation.clone(); let mut consumed = 0usize; for item in page.items { - let (snippet, snippet_truncated) = truncate_subjektiv_session_snippet(&item.compact); + let item_scan_cursor = item.scan_cursor; + let (snippet, locally_truncated) = truncate_subjektiv_session_snippet(&item.compact); + let snippet_truncated = item.compact_truncated || locally_truncated; let projected = server_api::SubjektivSessionSearchItem { session_id: attribution.session_id.clone(), segment_id: item.segment_id, @@ -17841,7 +17869,7 @@ fn subjektiv_session_search( storage: source.storage, archive_id: source.archive_id.clone(), generation: generation.clone(), - offset: offset.saturating_add(consumed), + runtime_scan_cursor: item_scan_cursor, }); break; } @@ -17852,9 +17880,12 @@ fn subjektiv_session_search( break; } if page.has_more { - let next_offset = page - .next_offset - .unwrap_or_else(|| offset.saturating_add(consumed)); + let Some(next_scan_cursor) = page.next_scan_cursor else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session search response omitted its continuation position", + ); + }; has_more = true; state.current = Some(SubjektivSessionSearchPosition { attributed_at: attribution.attributed_at, @@ -17864,7 +17895,7 @@ fn subjektiv_session_search( storage: source.storage, archive_id: source.archive_id, generation, - offset: next_offset, + runtime_scan_cursor: next_scan_cursor, }); break; } @@ -17873,13 +17904,18 @@ fn subjektiv_session_search( break; } if items.len() >= limit { - has_more = true; break; } } - if scanned_sessions >= SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS && state.session_id.is_none() { - has_more = true; + let (has_more, scan_budget_partial) = subjektiv_session_search_completion( + has_more, + items.len(), + limit, + scanned_sessions, + state.session_id.is_none(), + ); + if scan_budget_partial { state.partial = true; } if !issues.is_empty() && successful_sources == 0 && !has_more { @@ -17916,6 +17952,20 @@ fn subjektiv_session_search( } } +fn subjektiv_session_search_completion( + has_unscanned: bool, + item_count: usize, + limit: usize, + scanned_sessions: usize, + cross_session: bool, +) -> (bool, bool) { + debug_assert!(item_count <= limit); + let scan_budget_partial = cross_session + && scanned_sessions >= SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS + && has_unscanned; + (has_unscanned, scan_budget_partial) +} + fn subjektiv_session_limit( limit: Option, ) -> std::result::Result { @@ -17983,6 +18033,13 @@ fn subjektiv_session_error( } } +fn subjektiv_session_response_fits_model_budget( + response: &server_api::SubjektivSessionBackendResponse, +) -> std::result::Result { + serde_json::to_vec(response) + .map(|serialized| serialized.len() <= server_api::SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES) +} + fn bounded_subjektiv_session_message(message: &str) -> String { truncate_subjektiv_session_utf8(message, 512) } @@ -37353,6 +37410,174 @@ mod tests { )); } + #[test] + fn subjektiv_session_search_exact_boundaries_are_complete() { + assert_eq!( + subjektiv_session_search_completion( + false, + 20, + 20, + SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS, + true, + ), + (false, false), + "exactly limit results at the end must not invent a continuation" + ); + assert_eq!( + subjektiv_session_search_completion( + false, + 0, + 20, + SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS, + true, + ), + (false, false), + "exactly 20 exhausted Sessions must remain complete" + ); + assert_eq!( + subjektiv_session_search_completion( + true, + 0, + 20, + SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS, + true, + ), + (true, true), + "a real unscanned twenty-first Session must remain partial" + ); + } + + #[tokio::test] + async fn subjektiv_session_list_exactly_twenty_sessions_has_no_false_continuation() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Session boundary Worker".to_string(), + singleton_key: None, + profile: None, + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let controller = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id); + let store = open_subjektiv_store(&api).unwrap(); + let subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + for index in 0..20 { + let mut attribution = crate::subjektiv::SubjectSessionAttribution::new( + &subject.id, + &controller.runtime_id, + &controller.worker_id, + format!("session-{index:02}"), + ) + .unwrap(); + attribution.attributed_at = format!("2026-10-02T00:00:{index:02}Z"); + store.record_session_attribution(attribution).unwrap(); + } + api.store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + grant_id: "session-boundary-observe".to_string(), + controller: controller.clone(), + subject: controller.clone(), + relation: "subject_session".to_string(), + origin: "test".to_string(), + permissions: vec!["observe".to_string()], + operation_id: "session-boundary-observe".to_string(), + created_at: Utc::now().to_rfc3339(), + revoked_at: None, + }) + .unwrap(); + + let response = subjektiv_session_list( + &api, + &store, + TEST_WORKSPACE_ID, + &subject.id, + &controller, + server_api::SubjektivSessionListRequest { + session_id: None, + storage: server_api::SubjektivSessionStorageFilter::All, + limit: Some(20), + cursor: None, + }, + ); + assert!(matches!( + response, + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { + ref items, + next_cursor: None, + has_more: false, + } + ) + } if items.len() == 20 + )); + } + + #[test] + fn subjektiv_session_read_budget_uses_serialized_json_and_preserves_pages() { + fn response( + content: String, + has_more: bool, + ) -> server_api::SubjektivSessionBackendResponse { + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Read( + server_api::SubjektivSessionReadResponse { + session_id: "session-a".into(), + segment_id: "segment-a".into(), + entry_ref: "Eentry-a".into(), + kind: server_api::SubjektivSessionEntryKind::User, + origin: protocol::SessionEntryProvenance::HumanInput, + lineage: server_api::SubjektivSessionLineage { + kind: server_api::SubjektivSessionLineageKind::Root, + parent_segment_id: None, + at_turn_index: None, + }, + mode: server_api::SubjektivSessionReadMode::Full, + content, + truncated: has_more, + next_cursor: has_more.then(|| "opaque-cursor".into()), + has_more, + }, + ), + } + } + + let content = "\0".repeat(server_api::SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES); + assert!( + !subjektiv_session_response_fits_model_budget(&response(content.clone(), true)) + .unwrap(), + "the raw 16 KiB limit is not safe for JSON-escaped content" + ); + let split = content.len() / 2; + let first = response(content[..split].to_string(), true); + let second = response(content[split..].to_string(), false); + assert!(subjektiv_session_response_fits_model_budget(&first).unwrap()); + assert!(subjektiv_session_response_fits_model_budget(&second).unwrap()); + let chunks = [first, second] + .into_iter() + .map(|response| match response { + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Read(read), + } => read.content, + _ => unreachable!(), + }) + .collect::(); + assert_eq!(chunks, content); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 5cb03fd1b..d2e4b76f9 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -7451,14 +7451,22 @@ impl ControlPlaneStore for SqliteWorkspaceStore { grant_id: &str, revoked_at: &str, ) -> Result { - self.with_conn(|conn| { - let changed = conn.execute( + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let active_changed = tx.execute( r#"UPDATE worker_control_grants SET revoked_at = ?3 WHERE workspace_id = ?1 AND grant_id = ?2 AND revoked_at IS NULL"#, params![workspace_id, grant_id, revoked_at], )?; - Ok(changed > 0) + let archived_changed = tx.execute( + r#"UPDATE worker_session_archive_observe_grants + SET revoked_at = ?3 + WHERE workspace_id = ?1 AND source_grant_id = ?2 AND revoked_at IS NULL"#, + params![workspace_id, grant_id, revoked_at], + )?; + tx.commit()?; + Ok(active_changed > 0 || archived_changed > 0) }) } @@ -14173,6 +14181,7 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { subject_worker_id TEXT NOT NULL, source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, + revoked_at TEXT, PRIMARY KEY ( workspace_id, archive_id, controller_runtime_id, controller_worker_id, From ec7a6f59c827d92b0039c759fe1033429b22a526 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 18:00:30 +0900 Subject: [PATCH 28/31] fix: complete subject session paging --- Cargo.lock | 1 + crates/session-store/src/lib.rs | 9 +- crates/session-store/src/public_index.rs | 123 ++++ crates/worker-runtime/src/session_public.rs | 132 ++--- crates/workspace-server/Cargo.toml | 1 + crates/workspace-server/src/server.rs | 589 +++++++++++++++++--- 6 files changed, 700 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41fb46abc..1afc18527 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6403,6 +6403,7 @@ dependencies = [ "serde_json", "serde_yaml", "server-api", + "session-store", "sha2 0.11.0", "ssh-key", "tempfile", diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index 0a16e0451..ac44c0be4 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -100,10 +100,11 @@ pub use public_index::{ DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, - SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, - SessionPublicIndexPage, SessionPublicIndexPageEntry, SessionPublicIndexReadError, - SessionPublicIndexScanPosition, SessionPublicIndexSegment, SessionPublicIndexToolPart, - read_session_public_index, read_session_public_index_page, + SessionPublicIndexEntryRead, SessionPublicIndexLimits, SessionPublicIndexLineage, + SessionPublicIndexOriginKind, SessionPublicIndexPage, SessionPublicIndexPageEntry, + SessionPublicIndexReadError, SessionPublicIndexScanPosition, SessionPublicIndexSegment, + SessionPublicIndexToolPart, read_session_public_index, read_session_public_index_entry, + read_session_public_index_page, }; pub use public_snapshot::{ DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index b5176e1df..520065b25 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -95,6 +95,16 @@ pub struct SessionPublicIndexPage { pub scanned_entries: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPublicIndexEntryRead { + pub session_id: String, + pub generation: String, + pub item: SessionPublicIndexPageEntry, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionPublicIndex { pub session_id: String, @@ -558,6 +568,76 @@ where }) } +/// Read one exact public entry from its selected Segment without charging +/// unrelated Segments against the caller's aggregate scan budget. The Session +/// generation still covers every persisted Segment so content-page cursors are +/// invalidated by any concurrent Session mutation. +pub fn read_session_public_index_entry( + session_root: &Path, + limits: SessionPublicIndexLimits, + expected_generation: Option<&str>, + segment_id: &str, + entry_ref: &str, +) -> Result, SessionPublicIndexReadError> { + if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + let selected_segment = match uuid::Uuid::parse_str(segment_id) { + Ok(segment_id) => segment_id, + Err(_) => return Ok(None), + }; + let store = WorkerSessionStore::open_read_only(session_root).map_err(map_store_open_error)?; + let session_id = store + .session_id() + .map_err(map_store_error)? + .ok_or(SessionPublicIndexReadError::Missing)?; + let segment_ids = store + .list_segments_read_only(session_id) + .map_err(map_store_error)?; + let Some(segment_index) = segment_ids + .iter() + .position(|candidate| *candidate == selected_segment) + else { + return Ok(None); + }; + let page = read_session_public_index_page( + session_root, + SessionPublicIndexLimits { + max_segments: 1, + ..limits + }, + expected_generation, + Some(SessionPublicIndexScanPosition { + segment_index, + byte_offset: 0, + entry_offset: 0, + lineage: None, + }), + 2, + |entry| entry.entry_ref == entry_ref, + )?; + let mut matches = page.entries.into_iter(); + let item = matches.next(); + if matches.next().is_some() { + return Err(SessionPublicIndexReadError::Corrupt); + } + if page + .next_position + .as_ref() + .is_some_and(|position| position.segment_index == segment_index) + { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + Ok(item.map(|item| SessionPublicIndexEntryRead { + session_id: page.session_id, + generation: page.generation, + item, + scanned_bytes: page.scanned_bytes, + scanned_segments: page.scanned_segments, + scanned_entries: page.scanned_entries, + })) +} + #[derive(Debug)] struct PublicLogUnit { entries: Vec, @@ -1687,6 +1767,49 @@ mod tests { } } + #[test] + fn incremental_scan_exact_aggregate_budgets_finish_without_cursor() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let first_segment = segment_id(1_600); + let second_segment = segment_id(1_601); + for (segment_id, id) in [(first_segment, "entry-a"), (second_segment, "entry-b")] { + store + .create_segment( + session_id, + segment_id, + &[start( + session_id, + vec![message(id, LoggedRole::User, "ordinary")], + None, + None, + )], + ) + .unwrap(); + } + let total_bytes = store.segment_log_len(first_segment).unwrap() + + store.segment_log_len(second_segment).unwrap(); + for limits in [ + SessionPublicIndexLimits { + max_bytes: total_bytes, + ..SessionPublicIndexLimits::default() + }, + SessionPublicIndexLimits { + max_segments: 2, + ..SessionPublicIndexLimits::default() + }, + SessionPublicIndexLimits { + max_entries: 4, + ..SessionPublicIndexLimits::default() + }, + ] { + let page = + read_session_public_index_page(&root, limits, None, None, 10, |_| false).unwrap(); + assert!(!page.has_more, "exact final-range budget must be complete"); + assert!(page.next_position.is_none()); + } + } + #[test] fn incremental_scan_rejects_one_indivisible_record_over_the_byte_budget() { let (_temp, root, store) = create_store(); diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index c8a9c486d..48906b000 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -10,10 +10,10 @@ use runtime_api::{ }; use serde::{Deserialize, Serialize}; use session_store::{ - SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, - SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, - SessionPublicIndexReadError, SessionPublicIndexScanPosition, SessionPublicIndexToolPart, - read_session_public_index, read_session_public_index_page, + SessionPublicIndexEntry, SessionPublicIndexEntryKind, SessionPublicIndexLimits, + SessionPublicIndexLineage, SessionPublicIndexOriginKind, SessionPublicIndexReadError, + SessionPublicIndexScanPosition, SessionPublicIndexToolPart, read_session_public_index_entry, + read_session_public_index_page, }; use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest; @@ -171,57 +171,37 @@ pub(crate) fn read( "Session public read content limit must be positive", ); } - let index = match load_index( + let selected = match read_session_public_index_entry( session_root, - request.max_scan_bytes, - request.max_segments, - request.max_entries, + SessionPublicIndexLimits { + max_bytes: request.max_scan_bytes, + max_segments: request.max_segments, + max_entries: request.max_entries, + }, + request.expected_generation.as_deref(), + &request.segment_id, + &request.entry_ref, ) { - Ok(index) => index, - Err((reason, message)) => return read_unavailable(reason, message), + Ok(Some(selected)) => selected, + Ok(None) => { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session entry was not found in the authorized source", + ); + } + Err(error) => { + let (reason, message) = map_index_error(error); + return read_unavailable(reason, message); + } }; - if index.session_id != request.expected_session_id { + if selected.session_id != request.expected_session_id { return read_unavailable( SessionPublicUnavailableReason::NotFound, "Session identity does not match the authorized source", ); } - if request - .expected_generation - .as_deref() - .is_some_and(|expected| expected != index.generation) - { - return read_unavailable( - SessionPublicUnavailableReason::InvalidCursor, - "Session public read generation changed", - ); - } - let Some(segment) = index - .segments - .iter() - .find(|segment| segment.segment_id == request.segment_id) - else { - return read_unavailable( - SessionPublicUnavailableReason::NotFound, - "Session entry was not found in the authorized source", - ); - }; - let mut entries = segment - .entries - .iter() - .filter(|entry| entry.entry_ref == request.entry_ref); - let Some(entry) = entries.next() else { - return read_unavailable( - SessionPublicUnavailableReason::NotFound, - "Session entry was not found in the authorized source", - ); - }; - if entries.next().is_some() { - return read_unavailable( - SessionPublicUnavailableReason::CorruptLog, - "Session entry identity is ambiguous in its Segment", - ); - } + let item = selected.item; + let entry = item.entry; let content = match request.mode { SessionPublicReadMode::Compact => &entry.compact_text, SessionPublicReadMode::Full => &entry.full_text, @@ -248,42 +228,25 @@ pub(crate) fn read( let has_more = end < content.len(); SessionPublicReadAvailability::Page { page: SessionPublicReadPage { - session_id: index.session_id, - generation: index.generation, - segment_id: segment.segment_id.clone(), + session_id: selected.session_id, + generation: selected.generation, + segment_id: item.segment_id, entry_ref: entry.entry_ref.clone(), kind: entry_kind(entry.kind), origin: entry.provenance.clone(), - lineage: lineage(&segment.lineage), + lineage: lineage(&item.lineage), mode: request.mode, content: content[request.byte_offset..end].to_string(), next_byte_offset: has_more.then_some(end), has_more, - scanned_bytes: index.scanned_bytes, - scanned_segments: index.segments.len(), - scanned_entries: index.scanned_entries, + scanned_bytes: selected.scanned_bytes, + scanned_segments: selected.scanned_segments, + scanned_entries: selected.scanned_entries, archive_manifest: archive_manifest.map(api_archive_manifest), }, } } -fn load_index( - session_root: &Path, - max_bytes: u64, - max_segments: usize, - max_entries: usize, -) -> Result { - read_session_public_index( - session_root, - SessionPublicIndexLimits { - max_bytes, - max_segments, - max_entries, - }, - ) - .map_err(map_index_error) -} - fn matches_entry( entry: &SessionPublicIndexEntry, request: &SessionPublicSearchRequest, @@ -522,6 +485,33 @@ mod tests { assert_eq!(second.items[0].compact, "needle"); assert!(!second.has_more); + let read_page = match read( + &root, + &SessionPublicReadRequest { + workspace_id: "workspace-1".into(), + source: runtime_api::SessionPublicSource::Retained { + worker_id: crate::identity::WorkerId::now_v7(), + }, + expected_session_id: session_id.to_string(), + expected_generation: Some(second.generation.clone()), + segment_id: last_segment.unwrap().to_string(), + entry_ref: "Eentry-64".into(), + mode: SessionPublicReadMode::Full, + byte_offset: 0, + max_content_bytes: 16 * 1024, + max_scan_bytes: 64 * 1024 * 1024, + max_segments: 64, + max_entries: 100_000, + }, + None, + ) { + SessionPublicReadAvailability::Page { page } => page, + other => panic!("unexpected direct read result: {other:?}"), + }; + assert_eq!(read_page.content, "needle"); + assert_eq!(read_page.segment_id, last_segment.unwrap().to_string()); + assert!(!read_page.has_more); + store .append( session_id, diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 5c78de89c..94d5c0bb7 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -51,6 +51,7 @@ webauthn-rs = { workspace = true } [dev-dependencies] agen.workspace = true +session-store.workspace = true tempfile.workspace = true tools.workspace = true yoi = { path = "../yoi" } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 4347072a0..e8e3c16ee 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -17034,6 +17034,7 @@ struct SubjektivSessionSearchPosition { archive_id: Option, generation: String, runtime_scan_cursor: String, + attribution_has_more: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17630,92 +17631,98 @@ fn subjektiv_session_search( while items.len() < limit && scanned_sessions < SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS { if remaining_scan_bytes == 0 || remaining_segments == 0 || remaining_entries == 0 { - has_more = true; - state.partial = true; + if has_more || state.current.is_some() { + state.partial = true; + } break; } - let (attribution, expected_generation, runtime_scan_cursor, cursor_source) = - if let Some(current) = state.current.take() { - let page = match store.subject_session_attribution_page( - subject_id, - Some(¤t.session_id), - &state.snapshot_at, - None, - 1, - ) { - Ok(page) => page, - Err(error) => { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.to_string(), - ); - } - }; - let Some(attribution) = page.items.into_iter().next() else { + let ( + attribution, + expected_generation, + runtime_scan_cursor, + cursor_source, + attribution_has_more, + ) = if let Some(current) = state.current.take() { + let page = match store.subject_session_attribution_page( + subject_id, + Some(¤t.session_id), + &state.snapshot_at, + None, + 1, + ) { + Ok(page) => page, + Err(error) => { return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search attribution is no longer available", - ); - }; - if attribution.attributed_at != current.attributed_at - || attribution.runtime_id != current.runtime_id - || attribution.worker_id != current.worker_id - { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search attribution changed", + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), ); } - ( - attribution, - Some(current.generation), - Some(current.runtime_scan_cursor), - Some((current.storage, current.archive_id)), - ) - } else { - let page = match store.subject_session_attribution_page( - subject_id, - state.session_id.as_deref(), - &state.snapshot_at, - match ( - state.after_attributed_at.as_deref(), - state.after_session_id.as_deref(), - ) { - (Some(attributed_at), Some(session_id)) => { - Some((attributed_at, session_id)) - } - (None, None) => None, - _ => { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::StaleCursor, - "Session search cursor position is incomplete", - ); - } - }, - 1, - ) { - Ok(page) => page, - Err(error) => { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.to_string(), - ); - } - }; - let Some(attribution) = page.items.into_iter().next() else { - if state.session_id.is_some() { - return subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, - "Session was not found or is not authorized", - ); - } - break; - }; - has_more = page.has_more; - state.after_attributed_at = Some(attribution.attributed_at.clone()); - state.after_session_id = Some(attribution.session_id.clone()); - (attribution, None, None, None) }; + let Some(attribution) = page.items.into_iter().next() else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution is no longer available", + ); + }; + if attribution.attributed_at != current.attributed_at + || attribution.runtime_id != current.runtime_id + || attribution.worker_id != current.worker_id + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution changed", + ); + } + ( + attribution, + Some(current.generation), + Some(current.runtime_scan_cursor), + Some((current.storage, current.archive_id)), + current.attribution_has_more, + ) + } else { + let page = match store.subject_session_attribution_page( + subject_id, + state.session_id.as_deref(), + &state.snapshot_at, + match ( + state.after_attributed_at.as_deref(), + state.after_session_id.as_deref(), + ) { + (Some(attributed_at), Some(session_id)) => Some((attributed_at, session_id)), + (None, None) => None, + _ => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor position is incomplete", + ); + } + }, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + if state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + let attribution_has_more = page.has_more; + state.after_attributed_at = Some(attribution.attributed_at.clone()); + state.after_session_id = Some(attribution.session_id.clone()); + (attribution, None, None, None, attribution_has_more) + }; + has_more = attribution_has_more; scanned_sessions += 1; let source = match authorized_subjektiv_session( api, @@ -17870,6 +17877,7 @@ fn subjektiv_session_search( archive_id: source.archive_id.clone(), generation: generation.clone(), runtime_scan_cursor: item_scan_cursor, + attribution_has_more, }); break; } @@ -17896,6 +17904,7 @@ fn subjektiv_session_search( archive_id: source.archive_id, generation, runtime_scan_cursor: next_scan_cursor, + attribution_has_more, }); break; } @@ -37526,6 +37535,426 @@ mod tests { )); } + #[tokio::test] + async fn subjektiv_session_search_preserves_later_session_after_runtime_resume() { + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store as _, WorkerSessionStore, + }; + + fn public_message(id: &str, text: &str) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::User, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.to_string()), + origin: LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".to_string(), + }, + derivation: None, + }, + } + } + + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let create = |display_name: &str| CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: display_name.to_string(), + singleton_key: None, + profile: None, + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + control_operation_id: None, + }; + let Json(controller) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(create("Session search controller")), + ) + .await + .unwrap(); + let Json(first_worker) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(create("Large Session source")), + ) + .await + .unwrap(); + let Json(second_worker) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(create("Later Session source")), + ) + .await + .unwrap(); + let controller = RuntimeWorkerRef::new(&controller.runtime_id, &controller.worker_id); + let first_worker = RuntimeWorkerRef::new(&first_worker.runtime_id, &first_worker.worker_id); + let second_worker = + RuntimeWorkerRef::new(&second_worker.runtime_id, &second_worker.worker_id); + + let first_session = session_store::new_session_id(); + let first_root = workspace + .path() + .join(".test-embedded-runtime-store/workers") + .join(&first_worker.worker_id) + .join("session"); + let first_store = WorkerSessionStore::new(&first_root).unwrap(); + for index in 0..65_u128 { + first_store + .create_segment( + first_session, + uuid::Uuid::from_u128(20_000 + index), + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: first_session, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: vec![public_message( + &format!("first-{index}"), + if index == 64 { "needle" } else { "ordinary" }, + )], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + } + let second_session = session_store::new_session_id(); + let second_root = workspace + .path() + .join(".test-embedded-runtime-store/workers") + .join(&second_worker.worker_id) + .join("session"); + WorkerSessionStore::new(&second_root) + .unwrap() + .create_segment( + second_session, + uuid::Uuid::from_u128(30_000), + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: second_session, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: vec![public_message("second", "needle in later Session")], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + + let store = open_subjektiv_store(&api).unwrap(); + let subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + for (worker, session_id, attributed_at) in [ + (&first_worker, first_session, "2026-10-02T00:00:02Z"), + (&second_worker, second_session, "2026-10-02T00:00:01Z"), + ] { + let mut attribution = crate::subjektiv::SubjectSessionAttribution::new( + &subject.id, + &worker.runtime_id, + &worker.worker_id, + session_id.to_string(), + ) + .unwrap(); + attribution.attributed_at = attributed_at.to_string(); + store.record_session_attribution(attribution).unwrap(); + api.store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + grant_id: format!("search-observe-{}", worker.worker_id), + controller: controller.clone(), + subject: worker.clone(), + relation: "subject_session".to_string(), + origin: "test".to_string(), + permissions: vec!["observe".to_string()], + operation_id: format!("search-observe-{}", worker.worker_id), + created_at: Utc::now().to_rfc3339(), + revoked_at: None, + }) + .unwrap(); + } + + let search = |cursor| { + subjektiv_session_search( + &api, + &store, + TEST_WORKSPACE_ID, + &subject.id, + &controller, + server_api::SubjektivSessionSearchRequest { + query: Some("needle".to_string()), + session_id: None, + kind: None, + tool_name: None, + tool_part: server_api::SubjektivSessionToolPart::Both, + limit: Some(1), + cursor, + }, + ) + }; + let page = |response| match response { + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Search(page), + } => page, + other => panic!("unexpected Session search response: {other:?}"), + }; + + let first = page(search(None)); + assert!(first.items.is_empty()); + assert!(first.has_more); + let second = page(search(first.next_cursor)); + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].session_id, first_session.to_string()); + assert!( + second.has_more, + "the outer attribution continuation must survive a resumed Runtime page" + ); + let third = page(search(second.next_cursor)); + assert_eq!(third.items.len(), 1); + assert_eq!(third.items[0].session_id, second_session.to_string()); + assert!(!third.has_more); + assert!(third.next_cursor.is_none()); + } + + #[tokio::test] + async fn archived_session_search_and_read_reauthorize_after_grant_revocation() { + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store as _, WorkerSessionStore, + }; + + fn public_message(id: &str, text: &str) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::User, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.to_string()), + origin: LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".to_string(), + }, + derivation: None, + }, + } + } + + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let Json(orchestrator) = scoped_start_workspace_orchestrator( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + ) + .await + .unwrap(); + let controller = orchestrator.worker.unwrap(); + let controller = RuntimeWorkerRef::new(&controller.runtime_id, &controller.worker_id); + let spawned = api + .spawn_workspace_worker( + EMBEDDED_WORKER_RUNTIME_ID, + WorkerSpawnRequest { + intent: WorkerSpawnIntent::WorkspaceCompanion, + requested_worker_name: Some("archived Session source".to_string()), + singleton_key: None, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: worker_runtime::catalog::ProfileSelector::Builtin( + "builtin:companion".to_string(), + ), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: Some(runtime_test_bundle()), + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_control_operation: None, + }, + ) + .unwrap(); + let target = spawned.worker.unwrap().worker; + let stopped = api + .runtime + .stop_worker( + &target, + WorkerLifecycleRequest { + reason: Some("prepare archive revocation regression".to_string()), + ticket_assignment: None, + }, + ) + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + + let session_id = session_store::new_session_id(); + let segment_id = uuid::Uuid::from_u128(40_000); + let session_root = workspace + .path() + .join(".test-embedded-runtime-store/workers") + .join(&target.worker_id) + .join("session"); + WorkerSessionStore::new(&session_root) + .unwrap() + .create_segment( + session_id, + segment_id, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: vec![ + public_message("archive-a", "archived first entry"), + public_message("archive-b", "archived second entry"), + ], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + let subjektiv_store = open_subjektiv_store(&api).unwrap(); + let subject = subjektiv_store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + subjektiv_store + .record_session_attribution( + crate::subjektiv::SubjectSessionAttribution::new( + &subject.id, + &target.runtime_id, + &target.worker_id, + session_id.to_string(), + ) + .unwrap(), + ) + .unwrap(); + let grant_id = "archive-revocable-observe"; + api.store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + grant_id: grant_id.to_string(), + controller: controller.clone(), + subject: target.clone(), + relation: "subject_session".to_string(), + origin: "test".to_string(), + permissions: vec!["observe".to_string(), "remove".to_string()], + operation_id: "archive-revocation-proof".to_string(), + created_at: Utc::now().to_rfc3339(), + revoked_at: None, + }) + .unwrap(); + let summary = api.runtime.worker(&target).unwrap(); + sync_worker_observation(&api, &summary).unwrap(); + let removal = WorkerRemovalService::new(&api) + .execute_async( + crate::worker_source::VerifiedWorkerMutationSource { + runtime_id: controller.runtime_id.clone(), + worker_id: controller.worker_id.clone(), + actor_kind: worker_runtime::auth::WorkerMutationActorKind::Worker, + permission: worker_runtime::auth::WORKER_REMOVE_PERMISSION.to_string(), + jti: "archive-revocation-proof".to_string(), + }, + &target.runtime_id, + &target.worker_id, + "archive Session for revocation regression", + ) + .await + .unwrap(); + assert_eq!(removal.status, StatusCode::OK.as_u16(), "{}", removal.body); + assert!( + api.config_store + .worker_session_archive(TEST_WORKSPACE_ID, &target, &session_id.to_string()) + .unwrap() + .is_some(), + "removal must commit the archive catalog before backend reads" + ); + + let search_request = |cursor| server_api::SubjektivSessionSearchRequest { + query: None, + session_id: Some(session_id.to_string()), + kind: None, + tool_name: None, + tool_part: server_api::SubjektivSessionToolPart::Both, + limit: Some(1), + cursor, + }; + let first = subjektiv_session_search( + &api, + &subjektiv_store, + TEST_WORKSPACE_ID, + &subject.id, + &controller, + search_request(None), + ); + let cursor = match first { + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Search(page), + } => { + assert_eq!(page.items.len(), 1); + assert!(page.has_more); + page.next_cursor.unwrap() + } + other => panic!("unexpected archived Session search response: {other:?}"), + }; + + assert!( + api.store + .revoke_worker_control_grant(TEST_WORKSPACE_ID, grant_id, &Utc::now().to_rfc3339()) + .unwrap() + ); + assert!(matches!( + subjektiv_session_search( + &api, + &subjektiv_store, + TEST_WORKSPACE_ID, + &subject.id, + &controller, + search_request(Some(cursor)), + ), + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + .. + } + } + )); + assert!(matches!( + subjektiv_session_read( + &api, + &subjektiv_store, + TEST_WORKSPACE_ID, + &subject.id, + &controller, + server_api::SubjektivSessionReadRequest { + session_id: session_id.to_string(), + segment_id: segment_id.to_string(), + entry_ref: "Earchive-a".to_string(), + mode: server_api::SubjektivSessionReadMode::Full, + cursor: None, + }, + ), + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + .. + } + } + )); + } + #[test] fn subjektiv_session_read_budget_uses_serialized_json_and_preserves_pages() { fn response( From 07fcc249fef034e98485c9a56cd42bed1b15c8e7 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 18:11:51 +0900 Subject: [PATCH 29/31] fix: resume exact reads within large segments --- crates/session-store/src/public_index.rs | 154 +++++++++++++++----- crates/worker-runtime/src/session_public.rs | 85 +++++++++++ 2 files changed, 206 insertions(+), 33 deletions(-) diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index 520065b25..d569c09b7 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -600,41 +600,56 @@ pub fn read_session_public_index_entry( else { return Ok(None); }; - let page = read_session_public_index_page( - session_root, - SessionPublicIndexLimits { - max_segments: 1, - ..limits - }, - expected_generation, - Some(SessionPublicIndexScanPosition { - segment_index, - byte_offset: 0, - entry_offset: 0, - lineage: None, - }), - 2, - |entry| entry.entry_ref == entry_ref, - )?; - let mut matches = page.entries.into_iter(); - let item = matches.next(); - if matches.next().is_some() { - return Err(SessionPublicIndexReadError::Corrupt); + let mut position = SessionPublicIndexScanPosition { + segment_index, + byte_offset: 0, + entry_offset: 0, + lineage: None, + }; + let mut generation = expected_generation.map(str::to_string); + let mut selected = None; + let mut scanned_bytes = 0_u64; + let mut scanned_segments = 0_usize; + let mut scanned_entries = 0_usize; + loop { + let page = read_session_public_index_page( + session_root, + SessionPublicIndexLimits { + max_segments: 1, + ..limits + }, + generation.as_deref(), + Some(position.clone()), + 2, + |entry| entry.entry_ref == entry_ref, + )?; + generation.get_or_insert_with(|| page.generation.clone()); + scanned_bytes = scanned_bytes.saturating_add(page.scanned_bytes); + scanned_segments = scanned_segments.saturating_add(page.scanned_segments); + scanned_entries = scanned_entries.saturating_add(page.scanned_entries); + for item in page.entries { + if selected.replace(item).is_some() { + return Err(SessionPublicIndexReadError::Corrupt); + } + } + let Some(next_position) = page.next_position else { + break; + }; + if next_position.segment_index != segment_index { + break; + } + if next_position == position { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + position = next_position; } - if page - .next_position - .as_ref() - .is_some_and(|position| position.segment_index == segment_index) - { - return Err(SessionPublicIndexReadError::ResourceLimit); - } - Ok(item.map(|item| SessionPublicIndexEntryRead { - session_id: page.session_id, - generation: page.generation, + Ok(selected.map(|item| SessionPublicIndexEntryRead { + session_id: session_id.to_string(), + generation: generation.expect("a successful index page always has a generation"), item, - scanned_bytes: page.scanned_bytes, - scanned_segments: page.scanned_segments, - scanned_entries: page.scanned_entries, + scanned_bytes, + scanned_segments, + scanned_entries, })) } @@ -1810,6 +1825,79 @@ mod tests { } } + #[test] + fn exact_entry_read_resumes_within_selected_segment_across_byte_and_entry_budgets() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let segment_id = segment_id(1_700); + store + .create_segment( + session_id, + segment_id, + &[start( + session_id, + vec![message("seed", LoggedRole::User, "ordinary seed")], + None, + None, + )], + ) + .unwrap(); + for index in 0..8 { + let id = if index == 7 { + "target".to_string() + } else { + format!("ordinary-{index}") + }; + store + .append( + session_id, + segment_id, + &LogEntry::AnnotatedAssistantItem { + ts: 2 + index, + entry: message( + &id, + LoggedRole::Assistant, + if index == 7 { "needle" } else { "ordinary" }, + ), + }, + ) + .unwrap(); + } + let (_, first_record_bytes, _) = store + .read_first_log_record_read_only_bounded(session_id, segment_id, u64::MAX) + .unwrap(); + + let by_bytes = read_session_public_index_entry( + &root, + SessionPublicIndexLimits { + max_bytes: first_record_bytes, + ..SessionPublicIndexLimits::default() + }, + None, + &segment_id.to_string(), + "Etarget", + ) + .unwrap() + .unwrap(); + assert_eq!(by_bytes.item.entry.full_text, "needle"); + assert!(by_bytes.scanned_bytes > first_record_bytes); + + let by_entries = read_session_public_index_entry( + &root, + SessionPublicIndexLimits { + max_entries: 2, + ..SessionPublicIndexLimits::default() + }, + None, + &segment_id.to_string(), + "Etarget", + ) + .unwrap() + .unwrap(); + assert_eq!(by_entries.item.entry.full_text, "needle"); + assert!(by_entries.scanned_entries > 2); + } + #[test] fn incremental_scan_rejects_one_indivisible_record_over_the_byte_budget() { let (_temp, root, store) = create_store(); diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index 48906b000..60b154ac9 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -534,4 +534,89 @@ mod tests { } )); } + + #[test] + fn search_then_read_crosses_entry_budget_within_one_segment() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + let store = WorkerSessionStore::new(&root).unwrap(); + let session_id = session_store::new_session_id(); + let segment_id = uuid::Uuid::from_u128(20_000); + store + .create_segment( + session_id, + segment_id, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history: vec![message("seed", "ordinary")], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + for index in 0..4 { + let id = if index == 3 { + "target".to_string() + } else { + format!("ordinary-{index}") + }; + store + .append( + session_id, + segment_id, + &LogEntry::AnnotatedAssistantItem { + ts: 2 + index, + entry: message(&id, if index == 3 { "needle" } else { "ordinary" }), + }, + ) + .unwrap(); + } + + let mut generation = None; + let mut scan_cursor = None; + let found = loop { + let mut search_request = request(session_id, generation.clone(), scan_cursor.clone()); + search_request.max_entries = 2; + let page = match search(&root, &search_request, None) { + SessionPublicSearchAvailability::Page { page } => page, + other => panic!("unexpected search result: {other:?}"), + }; + if let Some(item) = page.items.into_iter().next() { + break (page.generation, item); + } + assert!(page.has_more); + generation = Some(page.generation); + scan_cursor = page.next_scan_cursor; + }; + assert_eq!(found.1.entry_ref, "Etarget"); + + let read_page = match read( + &root, + &SessionPublicReadRequest { + workspace_id: "workspace-1".into(), + source: runtime_api::SessionPublicSource::Retained { + worker_id: crate::identity::WorkerId::now_v7(), + }, + expected_session_id: session_id.to_string(), + expected_generation: Some(found.0), + segment_id: segment_id.to_string(), + entry_ref: found.1.entry_ref, + mode: SessionPublicReadMode::Full, + byte_offset: 0, + max_content_bytes: 16 * 1024, + max_scan_bytes: 64 * 1024 * 1024, + max_segments: 64, + max_entries: 2, + }, + None, + ) { + SessionPublicReadAvailability::Page { page } => page, + other => panic!("unexpected direct read result: {other:?}"), + }; + assert_eq!(read_page.content, "needle"); + assert!(read_page.scanned_entries > 2); + } } From f4966b78eaeb805c99cfafe21c9d0a371d81be6e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 3 Oct 2026 00:06:47 +0900 Subject: [PATCH 30/31] feat: cut over Memory to subjektiv product surfaces --- crates/client/src/workspace_product.rs | 134 +- crates/manifest/src/profile.rs | 73 +- crates/server-api/src/lib.rs | 337 ++++- crates/worker/src/controller.rs | 152 +- crates/worker/src/feature/builtin/memory.rs | 502 ++++++- .../src/feature/builtin/memory_lifecycle.rs | 140 +- .../builtin/memory_surface_lifecycle.rs | 28 +- crates/worker/tests/controller_test.rs | 19 +- crates/workspace-server/src/server.rs | 852 ++++++++++- crates/workspace-server/src/store.rs | 204 +++ crates/workspace-server/src/subjektiv.rs | 184 +++ docs/README.md | 5 +- docs/design/memory-knowledge.md | 18 +- docs/design/overview.md | 2 +- docs/design/server-feature-storage.md | 4 +- docs/design/subjektiv-store.md | 47 +- docs/design/subjektiv-surface.md | 31 +- docs/development/subjektiv-product-cutover.md | 98 ++ docs/development/ui-ux/product-ux.md | 17 +- docs/manifest.toml | 68 +- openapi/server-api.json | 1259 ++++++++++++++++- resources/profiles/backend-job.dcdl | 1 + resources/profiles/base.dcdl | 5 +- resources/profiles/coder.dcdl | 3 +- resources/profiles/companion.dcdl | 3 +- resources/profiles/default.dcdl | 1 + resources/profiles/intake.dcdl | 3 +- resources/profiles/memory-consolidation.dcdl | 14 +- resources/profiles/orchestrator.dcdl | 3 +- resources/profiles/reviewer.dcdl | 3 +- .../subjektiv-memory-consolidation.dcdl | 10 +- .../memory_document_fixture_server.ts | 285 +++- .../memory_document_shell_test.ts | 219 ++- .../scenarios/memory-subjects-dark.json | 113 ++ .../scenarios/memory-subjects-light.json | 113 ++ .../src/lib/generated/companion-api.ts | 2 +- web/workspace/src/lib/generated/memory-api.ts | 128 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../src/lib/workspace/api/workers.ts | 45 +- .../console/worker-console.ui.test.ts | 286 ++-- web/workspace/src/lib/workspace/memory/api.ts | 639 ++++++++- .../workspace/sidebar/MemoryNavSection.svelte | 23 +- .../workspace-web-ux/workspace-navigation.ts | 37 +- .../w/[workspaceId]/memory/+page.svelte | 295 ++-- .../routes/w/[workspaceId]/memory/+page.ts | 17 +- .../memory/[subjectId]/+page.svelte | 470 ++++++ .../[workspaceId]/memory/[subjectId]/+page.ts | 44 + .../[subjectId]/[memoryId]/+page.svelte | 626 ++++++++ .../memory/[subjectId]/[memoryId]/+page.ts | 82 ++ .../memory/memory-page.browser.spec.ts | 293 +++- web/workspace/tests/memory-api.test.ts | 503 +++++-- 54 files changed, 7600 insertions(+), 848 deletions(-) create mode 100644 docs/development/subjektiv-product-cutover.md create mode 100644 tools/web-ux/scenarios/memory-subjects-dark.json create mode 100644 tools/web-ux/scenarios/memory-subjects-light.json create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 0cacb0273..1032cda9c 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -3,8 +3,11 @@ use server_api::{ CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, - RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, - WorkspaceRuntimeResource, + RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery, + SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse, + SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, + WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use ticket::{ MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, @@ -410,6 +413,94 @@ impl BackendWorkspaceProductClient { crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit) } + pub fn list_subjektiv_subjects( + &self, + limit: usize, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + self.generated(move |client| async move { + client + .subjektiv_subject_list( + workspace_id, + SubjektivSubjectListQuery { limit: Some(limit) }, + ) + .await + }) + } + + pub fn subjektiv_subject( + &self, + subject_id: &str, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + self.generated(move |client| async move { + client.subjektiv_subject_get(workspace_id, subject_id).await + }) + } + + pub fn subjektiv_resident_surface( + &self, + subject_id: &str, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + self.generated(move |client| async move { + client + .subjektiv_resident_surface(workspace_id, subject_id) + .await + }) + } + + pub fn list_subjektiv_memories( + &self, + subject_id: &str, + query: &SubjektivMemoryListQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_list(workspace_id, subject_id, query) + .await + }) + } + + pub fn subjektiv_memory( + &self, + subject_id: &str, + memory_id: &str, + query: &SubjektivMemoryDetailQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let memory_id = memory_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_detail(workspace_id, subject_id, memory_id, query) + .await + }) + } + + pub fn list_subjektiv_memory_revisions( + &self, + subject_id: &str, + memory_id: &str, + query: &SubjektivMemoryRevisionsQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let memory_id = memory_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query) + .await + }) + } + pub fn launch_ticket_intake( &self, ticket_id: &str, @@ -917,6 +1008,45 @@ mod tests { handle.join().unwrap(); } + #[test] + fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() { + let (base_url, request, handle) = + one_response_server("200 OK", r#"{"items":[],"has_more":false}"#); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); + + let response = client + .list_subjektiv_memories( + "subject/a", + &SubjektivMemoryListQuery { + query: Some("stable cursor".to_string()), + kinds: None, + states: Some(vec![ + server_api::SubjektivMemoryState::Active, + server_api::SubjektivMemoryState::Resolved, + ]), + limit: Some(25), + cursor: None, + }, + ) + .unwrap(); + + assert!(response.items.is_empty()); + let request = request.recv().unwrap(); + assert!( + request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?") + ); + assert!(request.contains("query=stable+cursor")); + assert!(request.contains("states=active%2Cresolved")); + assert!(request.contains("limit=25")); + assert!(request.contains("authorization: Bearer test-backend-token\r\n")); + handle.join().unwrap(); + } + #[test] fn objective_list_uses_workspace_scoped_backend_route() { let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 78d91b1ff..3bbcb7c86 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1241,6 +1241,64 @@ mod tests { } } + #[test] + fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() { + let tmp = TempDir::new().unwrap(); + let resolve = |name: &str| { + ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, name), + ProfileResolveOptions::with_worker_name(format!("{name}-worker")), + ) + .unwrap() + .manifest + }; + + for name in ["coder", "companion", "intake", "reviewer", "orchestrator"] { + let manifest = resolve(name); + assert!( + manifest.feature.subjektiv.profile.enabled, + "{name} must enable subjektiv" + ); + assert!( + manifest.feature.subjektiv.profile.extraction.enabled, + "{name} must enable subject extraction" + ); + assert!( + !manifest.feature.memory.profile.enabled, + "{name} must not enable legacy Workspace Memory" + ); + } + + for name in ["default", "standalone", "backend-job"] { + let manifest = resolve(name); + assert!(!manifest.feature.memory.profile.enabled, "{name}"); + assert!(!manifest.feature.subjektiv.profile.enabled, "{name}"); + } + + let legacy_consolidator = resolve("memory-consolidation"); + assert!(legacy_consolidator.feature.memory.profile.enabled); + assert!(legacy_consolidator.feature.memory.profile.staging_tools); + assert!( + legacy_consolidator + .feature + .memory + .profile + .extraction + .enabled + ); + assert!( + legacy_consolidator + .feature + .memory + .profile + .consolidation + .request_enabled + ); + assert!(!legacy_consolidator.feature.subjektiv.profile.enabled); + } + #[test] fn builtin_profiles_pin_role_models_and_reasoning() { use crate::model::{ReasoningControl, ReasoningEffort}; @@ -1298,7 +1356,7 @@ mod tests { role.manifest.engine.reasoning, Some(ReasoningControl::Effort(ReasoningEffort::High)) ); - let extraction = &role.manifest.feature.memory.profile.extraction; + let extraction = &role.manifest.feature.subjektiv.profile.extraction; assert_eq!( extraction .model @@ -1341,12 +1399,10 @@ mod tests { let manifest = resolved.manifest; let feature = &manifest.feature; - assert!(feature.memory.profile.enabled); - assert!(feature.memory.profile.staging_tools); - assert!(!feature.memory.profile.resident.inject_summary); - assert!(!feature.memory.profile.extraction.enabled); - assert!(!feature.memory.profile.consolidation.request_enabled); - assert!(!feature.subjektiv.profile.enabled); + assert!(!feature.memory.profile.enabled); + assert!(!feature.memory.profile.staging_tools); + assert!(feature.subjektiv.profile.enabled); + assert!(!feature.subjektiv.profile.extraction.enabled); assert!(!feature.task.enabled); assert!(!feature.web.enabled); assert!(!feature.image.enabled); @@ -1518,7 +1574,8 @@ mod tests { panic!("unexpected error: {error}"); }; assert_eq!(target, ProfileExecutionTarget::Standalone); - assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv)); + assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket)); assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref())); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 73c6d0741..f580ce4d6 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -228,6 +228,8 @@ impl_openapi_schema!( FlowSourceResolveRequest, ResolvedFlowSource, MemoryDocumentResponse, + LegacyMemoryResetRequest, + LegacyMemoryResetResponse, MemoryStagingQuery, MemoryStagingListResponse, MemoryBackendRequest, @@ -235,7 +237,18 @@ impl_openapi_schema!( MemoryConsolidateStagingRequest, MemoryConsolidationResponse, SubjektivSubjectCreateRequest, + SubjektivSubjectListQuery, + SubjektivSubjectListResponse, SubjektivSubjectResponse, + SubjektivResidentSurfaceResponse, + SubjektivResidentSurfaceSnapshot, + SubjektivMemoryDetailQuery, + SubjektivMemoryRevisionsQuery, + SubjektivMemoryListQuery, + SubjektivMemoryQueryRequest, + SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, + SubjektivMemoryListRevisionsResponse, SubjektivSubjectWorkerStartRequest, SubjektivStageCandidateRequest, SubjektivStageCandidateResponse, @@ -1056,6 +1069,21 @@ pub trait ServerApi { #[path] workspace_id: String, ) -> Result; + #[post( + "/api/w/{workspace_id}/memory/reset", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn legacy_memory_reset( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: LegacyMemoryResetRequest, + ) -> Result; + #[get( "/api/w/{workspace_id}/memory/staging", status = 200, @@ -1110,6 +1138,19 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivSubjectCreateRequest, ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_list( + &self, + #[path] workspace_id: String, + #[query] query: SubjektivSubjectListQuery, + ) -> Result; #[get( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}", status = 200, @@ -1123,6 +1164,63 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_resident_surface( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_list( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[query] query: SubjektivMemoryListQuery, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_detail( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[path] memory_id: String, + #[query] query: SubjektivMemoryDetailQuery, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_revisions( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[path] memory_id: String, + #[query] query: SubjektivMemoryRevisionsQuery, + ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire", status = 200, @@ -4499,6 +4597,25 @@ pub struct SubjektivSubjectCreateRequest { pub role: String, } +pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectListResponse { + #[schemars(range(min = 1, max = 100))] + pub limit: usize, + pub items: Vec, + pub has_more: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectResponse { @@ -4515,6 +4632,150 @@ pub struct SubjektivSubjectResponse { pub current_worker: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivResidentSurfaceAvailability { + Ungenerated, + Stale, + Failed, + Ready, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentSurfaceSnapshot { + pub snapshot_id: String, + pub body_md: String, + #[serde(default)] + pub memory_refs: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub built_from_store_revision: u64, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentSurfaceResponse { + pub subject_id: String, + pub availability: SubjektivResidentSurfaceAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +fn serialize_optional_query_enum_list( + value: &Option>, + serializer: S, +) -> Result +where + T: Serialize, + S: serde::Serializer, +{ + let Some(items) = value else { + return serializer.serialize_none(); + }; + let mut encoded = Vec::with_capacity(items.len()); + for item in items { + match serde_json::to_value(item).map_err(serde::ser::Error::custom)? { + serde_json::Value::String(value) => encoded.push(value), + _ => { + return Err(serde::ser::Error::custom( + "query-list values must serialize as strings", + )); + } + } + } + serializer.serialize_some(&encoded.join(",")) +} + +fn deserialize_optional_query_enum_list<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + T: serde::de::DeserializeOwned, + D: serde::Deserializer<'de>, +{ + let Some(encoded) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + encoded + .split(',') + .map(|value| { + serde_json::from_value(serde_json::Value::String(value.to_string())) + .map_err(serde::de::Error::custom) + }) + .collect::, _>>() + .map(Some) +} + +/// Browser GET query for committed subject Memory. Enum lists use a bounded, +/// comma-separated scalar representation so URL-form encoding stays portable. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_query_enum_list", + deserialize_with = "deserialize_optional_query_enum_list" + )] + pub kinds: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_query_enum_list", + deserialize_with = "deserialize_optional_query_enum_list" + )] + pub states: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl From for SubjektivMemoryQueryRequest { + fn from(value: SubjektivMemoryListQuery) -> Self { + Self { + query: value.query, + kinds: value.kinds, + states: value.states, + limit: value.limit, + cursor: value.cursor, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryDetailQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub byte_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 1000))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_cursor: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionsQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectWorkerStartRequest { @@ -5197,6 +5458,7 @@ pub struct SubjektivSurfaceFailureResponse { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { + ResidentSummary(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), @@ -5368,6 +5630,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "result", content = "data", rename_all = "snake_case")] pub enum SubjektivMemoryBackendResponse { + ResidentSummary(memory::backend::MemoryResidentSummaryOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), @@ -9364,6 +9627,43 @@ pub struct MemoryDocumentResponse { pub record_source: String, } +pub const LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES: usize = 256; + +fn deserialize_legacy_memory_reset_confirmation<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + if value.len() > LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES { + return Err(serde::de::Error::custom( + "legacy Memory reset confirmation is too long", + )); + } + Ok(value) +} + +/// Explicit destructive reset request. The confirmation must exactly equal +/// `RESET LEGACY MEMORY ` for the Workspace named in the route. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LegacyMemoryResetRequest { + #[serde(deserialize_with = "deserialize_legacy_memory_reset_confirmation")] + #[schemars(length(max = 256))] + pub confirmation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LegacyMemoryResetResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub documents_deleted: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub staging_records_deleted: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub staging_resolutions_deleted: usize, +} + /// Candidate kinds exposed by the Memory staging resource. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -9541,12 +9841,47 @@ pub fn memory_api_typescript() -> String { MemoryStagingEntry::decl(&config), MemoryStagingListResponse::decl(&config), ]; + // These browser DTOs include nested types owned by `memory` and the Worker + // launch API. Keep their exported names explicit so the generated Web + // contract remains stable without requiring those crates to depend on + // `ts-rs` solely for this read-only projection. + let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; + +export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; + +export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; + +export type SubjektivMemoryState = "active" | "resolved" | "retracted"; + +export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; + +export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; + +export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; + +export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; + +export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; + +export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; + +export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, }; + +export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, }; + +export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; + +export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; + +export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; + +export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, };"#; let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" ); format!( - "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 2bce258c0..b0096c7df 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -784,10 +784,12 @@ impl WorkerController { // the actual registered set instead of a hand-maintained mirror. worker.engine().tool_server_handle().flush_pending(); - // Runtime-owned fresh Workers persist their initial model-visible head - // before controller exposure. Feature installation above must happen first - // so the resident contribution is loaded into that durable prompt. Restored - // Workers retain their established first-turn refresh lifecycle. + // Runtime-owned Workers persist their current model-visible head before + // controller exposure. Feature installation above must happen first so a + // fresh subject Worker captures the current resident surface in its durable + // prompt. Restored Workers defer their one-shot append-only resident refresh + // until the next model-visible context materialization, after the Runtime has + // committed restoration of the existing durable head. if runtime_managed && worker.needs_initial_session_head_materialization() { worker .materialize_durable_session_head() @@ -1179,18 +1181,17 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } -fn validate_automatic_memory_extraction_targets( +fn validate_memory_lifecycle_targets( memory: &manifest::MemoryFeatureProfileConfig, subjektiv: &manifest::SubjektivFeatureProfileConfig, ) -> std::io::Result<()> { - if memory.enabled - && memory.extraction.enabled - && subjektiv.enabled - && subjektiv.extraction.enabled - { + let legacy_lifecycle = + memory.enabled && (memory.extraction.enabled || memory.consolidation.request_enabled); + let subjektiv_lifecycle = subjektiv.enabled && subjektiv.extraction.enabled; + if legacy_lifecycle && subjektiv_lifecycle { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker", + "Workspace Memory and subjektiv lifecycles cannot both be enabled for one Worker", )); } Ok(()) @@ -1307,56 +1308,75 @@ where } let memory_profile = &worker.manifest().feature.memory.profile; let subjektiv_profile = &worker.manifest().feature.subjektiv.profile; - validate_automatic_memory_extraction_targets(memory_profile, subjektiv_profile)?; + validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?; let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare( worker.manifest(), worker.workspace_client_handle(), worker.prompts().load_full(), )?; - let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| { + let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| { ( plan.resident_summary_source.clone(), plan.system_prompt_override.clone(), ) }); - let memory_lifecycle_config = memory_install_plan - .as_ref() - .map(|plan| plan.resolved_config.clone()); + let memory_lifecycle_config = worker.manifest().feature.memory.clone(); if let Some(plan) = memory_install_plan { feature_registry.add_module(plan.module); } - if let Some(subjektiv_memory) = - crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( - &worker.manifest().feature.subjektiv, - worker.committed_session_capture_handle(), + let subjektiv_consolidation_plan = + crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare( + worker.manifest(), + worker.workspace_client_handle(), + worker.prompts().load_full(), + )?; + if let Some(plan) = subjektiv_consolidation_plan { + feature_prompt_contribution = Some((None, Some(plan.system_prompt_override))); + feature_registry.add_module(plan.module); + } + let ordinary_subjektiv_features_enabled = + crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest()); + if let Some(resident_summary_source) = + crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( + worker.manifest(), worker.workspace_client_handle(), )? + { + feature_prompt_contribution = Some((Some(resident_summary_source), None)); + } + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_memory) = + crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? { feature_registry.add_module(subjektiv_memory); } - if let Some(subjektiv_sessions) = - crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( - &worker.manifest().feature.subjektiv, - worker.committed_session_capture_handle(), - worker.workspace_client_handle(), - )? + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_sessions) = + crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? { feature_registry.add_module(subjektiv_sessions); } - if let Some(memory_config) = memory_lifecycle_config - && let Some(memory_lifecycle) = - crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( - worker.manifest_lifecycle_features_enabled(), - memory_config, - worker.committed_session_capture_handle(), - worker.session_extension_handle(), - worker.workspace_client_handle(), - spawner_manifest.clone(), - worker.llm_client_handle(), - prompts.clone(), - spawner_workspace_context.clone(), - worker.working_event_sender(), - )? + if let Some(memory_lifecycle) = + crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( + worker.manifest_lifecycle_features_enabled(), + memory_lifecycle_config, + worker.committed_session_capture_handle(), + worker.session_extension_handle(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + worker.working_event_sender(), + )? { feature_registry.add_module(memory_lifecycle); } @@ -1370,19 +1390,20 @@ where )? { feature_registry.add_module(surface_lifecycle); } - if let Some(subjektiv_lifecycle) = - crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( - worker.manifest_lifecycle_features_enabled(), - worker.manifest().feature.subjektiv.clone(), - worker.committed_session_capture_handle(), - worker.session_extension_handle(), - worker.workspace_client_handle(), - spawner_manifest.clone(), - worker.llm_client_handle(), - prompts.clone(), - spawner_workspace_context.clone(), - worker.working_event_sender(), - )? + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_lifecycle) = + crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( + worker.manifest_lifecycle_features_enabled(), + worker.manifest().feature.subjektiv.clone(), + worker.committed_session_capture_handle(), + worker.session_extension_handle(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + worker.working_event_sender(), + )? { feature_registry.add_module(subjektiv_lifecycle); } @@ -1592,7 +1613,7 @@ where ), )); } - if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution { + if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution { worker.install_system_prompt_contribution(resident_summary, system_prompt_override); } worker.attach_tracker(tracker); @@ -3570,9 +3591,17 @@ mod tests { let exposure = startup .find("let handle = WorkerHandle") .expect("controller handle exposure"); + let materialize_gate = startup[..materialize] + .rfind("if runtime_managed && worker.needs_initial_session_head_materialization() {") + .expect("fresh runtime-managed materialization gate"); - assert!(feature_install < materialize); + assert!(feature_install < materialize_gate); + assert!(materialize_gate < materialize); assert!(materialize < exposure); + assert!( + startup[materialize_gate..materialize] + .contains("needs_initial_session_head_materialization") + ); } #[test] @@ -3639,7 +3668,7 @@ mod tests { } #[test] - fn automatic_memory_extraction_destinations_are_mutually_exclusive() { + fn legacy_and_subjektiv_lifecycles_are_mutually_exclusive() { let mut memory = manifest::MemoryFeatureProfileConfig::default(); memory.enabled = true; memory.extraction.enabled = true; @@ -3647,12 +3676,17 @@ mod tests { subjektiv.enabled = true; subjektiv.extraction.enabled = true; - let error = validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap_err(); + let error = validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("cannot both be enabled")); - subjektiv.extraction.enabled = false; - validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap(); + memory.extraction.enabled = false; + assert!( + validate_memory_lifecycle_targets(&memory, &subjektiv).is_err(), + "legacy consolidation still installs the legacy lifecycle" + ); + memory.consolidation.request_enabled = false; + validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap(); } #[test] diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 85f8936da..e7f6fd0fe 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -650,6 +650,22 @@ struct WorkspaceResidentSummarySource { client: Arc, } +struct WorkspaceSubjektivResidentSummarySource { + client: Arc, +} + +const READY_EMPTY_RESIDENT_SURFACE: &str = + "The current ready resident memory surface is intentionally empty."; + +fn resident_summary_contribution( + output: memory::backend::MemoryResidentSummaryOutput, +) -> SystemPromptContribution { + if output.availability != memory::backend::MemoryResidentSummaryAvailability::Ready { + return SystemPromptContribution::Unavailable; + } + SystemPromptContribution::Ready(output.content.unwrap_or_default()) +} + #[async_trait] impl SystemPromptContributionSource for WorkspaceResidentSummarySource { async fn load(&self) -> SystemPromptContribution { @@ -662,14 +678,8 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { ) .await { - Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) - if output.availability - == memory::backend::MemoryResidentSummaryAvailability::Ready => - { - SystemPromptContribution::Ready(output.content.unwrap_or_default()) - } - Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(_)) => { - SystemPromptContribution::Unavailable + Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) => { + resident_summary_contribution(output) } Ok(other) => { tracing::debug!(?other, "unexpected resident Memory Backend result"); @@ -683,11 +693,39 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } } +#[async_trait] +impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { + async fn load(&self) -> SystemPromptContribution { + match execute_subjektiv_memory_operation( + self.client.as_ref(), + server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), + ) + .await + { + Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { + match resident_summary_contribution(output) { + SystemPromptContribution::Ready(content) if content.trim().is_empty() => { + SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) + } + contribution => contribution, + } + } + Ok(other) => { + tracing::debug!(?other, "unexpected subject resident Memory response"); + SystemPromptContribution::Unavailable + } + Err(error) => { + tracing::debug!(%error, "subject resident Memory unavailable"); + SystemPromptContribution::Unavailable + } + } + } +} + pub(crate) struct MemoryFeatureInstallPlan { pub(crate) module: MemoryToolsFeature, pub(crate) resident_summary_source: Option>, pub(crate) system_prompt_override: Option, - pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig, } impl MemoryFeatureInstallPlan { @@ -720,11 +758,7 @@ impl MemoryFeatureInstallPlan { } => Some(name.as_str()), _ => None, }); - let memory_consolidation_worker = matches!( - profile_name, - Some("memory-consolidation" | "subjektiv-memory-consolidation") - ); - let subjektiv_consolidation_worker = profile_name == Some("subjektiv-memory-consolidation"); + let memory_consolidation_worker = profile_name == Some("memory-consolidation"); config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; @@ -758,31 +792,170 @@ impl MemoryFeatureInstallPlan { let system_prompt_override = if memory_consolidation_worker { let language = settings.language; Some( - if subjektiv_consolidation_worker { - prompts.subjektiv_memory_consolidation_system(&language) - } else { - prompts.memory_consolidation_system(&language) - } - .map_err(|error| std::io::Error::other(error.to_string()))?, + prompts + .memory_consolidation_system(&language) + .map_err(|error| std::io::Error::other(error.to_string()))?, ) } else { None }; - let module = if subjektiv_consolidation_worker { - MemoryToolsFeature::from_tools(workspace_http_subjektiv_consolidation_tools(client)) - } else { - MemoryToolsFeature::new(client, config.profile.staging_tools) - }; + let module = MemoryToolsFeature::new(client, config.profile.staging_tools); Ok(Some(Self { module, resident_summary_source, system_prompt_override, - resolved_config: config, })) } } +pub(crate) fn is_builtin_subjektiv_consolidation_profile( + manifest: &manifest::WorkerManifest, +) -> bool { + manifest.profile.as_ref().is_some_and(|snapshot| { + matches!( + &snapshot.source, + manifest::ProfileSource::Registry { + source: manifest::ProfileRegistrySource::Builtin, + name, + .. + } if name == "subjektiv-memory-consolidation" + ) + }) +} + +pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool { + manifest.feature.subjektiv.profile.enabled + && !is_builtin_subjektiv_consolidation_profile(manifest) +} + +pub(crate) fn ordinary_subjektiv_resident_summary_source( + manifest: &manifest::WorkerManifest, + client: Arc, +) -> std::io::Result>> { + if !ordinary_subjektiv_features_enabled(manifest) { + return Ok(None); + } + manifest + .feature + .subjektiv + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !client.is_available() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv resident Memory requires Backend Workspace API authority", + )); + } + let workspace_id = client.workspace_id().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv resident Memory requires Backend Workspace API authority", + ) + })?; + let settings = manifest + .feature + .subjektiv + .workspace_settings() + .expect("validated enabled subjektiv config has Workspace settings"); + if settings.workspace_id != workspace_id { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "subjektiv Workspace settings belong to {} instead of {}", + settings.workspace_id, workspace_id + ), + )); + } + Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { + client, + }))) +} + +pub(crate) struct SubjektivConsolidationFeatureInstallPlan { + pub(crate) module: SubjektivConsolidationToolsFeature, + pub(crate) system_prompt_override: String, +} + +impl SubjektivConsolidationFeatureInstallPlan { + pub(crate) fn prepare( + manifest: &manifest::WorkerManifest, + client: Arc, + prompts: Arc, + ) -> std::io::Result> { + if !is_builtin_subjektiv_consolidation_profile(manifest) { + return Ok(None); + } + let config = &manifest.feature.subjektiv; + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !config.profile.enabled || config.profile.extraction.enabled { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv consolidation profile requires subject settings without automatic extraction", + )); + } + let workspace_id = client.workspace_id().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv consolidation tools require Backend Workspace API authority", + ) + })?; + let settings = config + .workspace_settings() + .expect("validated enabled subjektiv config has Workspace settings"); + if settings.workspace_id != workspace_id { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "subjektiv settings belong to {} instead of {}", + settings.workspace_id, workspace_id + ), + )); + } + let system_prompt_override = prompts + .subjektiv_memory_consolidation_system(&settings.language) + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(Some(Self { + module: SubjektivConsolidationToolsFeature { + tools: workspace_http_subjektiv_consolidation_tools(client), + }, + system_prompt_override, + })) + } +} + +#[derive(Clone)] +pub(crate) struct SubjektivConsolidationToolsFeature { + tools: Vec, +} + +impl FeatureModule for SubjektivConsolidationToolsFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin( + "subjektiv-memory-consolidation", + "subjektiv Memory Consolidation", + ) + .with_description("Dedicated subject Memory staging consolidation tools."); + for tool in &self.tools { + let (meta, _) = tool(); + descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description)); + } + descriptor + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for tool in &self.tools { + let (meta, _) = tool(); + context + .tools() + .register(ToolContribution::new(meta.name, tool.clone()))?; + } + Ok(()) + } +} + #[derive(Clone)] pub struct MemoryToolsFeature { tools: Vec, @@ -797,10 +970,6 @@ impl MemoryToolsFeature { }; Self { tools } } - - fn from_tools(tools: Vec) -> Self { - Self { tools } - } } impl FeatureModule for MemoryToolsFeature { @@ -828,7 +997,9 @@ impl FeatureModule for MemoryToolsFeature { #[cfg(test)] mod tests { use super::*; + use crate::WorkspaceResponse; use agen::tool::ToolDefinition; + use std::sync::Mutex; fn test_client() -> Arc { Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -837,13 +1008,100 @@ mod tests { )) } + #[derive(Debug)] + struct SubjectResidentClient { + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option, + scope_allowed: bool, + paths: Mutex>, + } + + impl SubjectResidentClient { + fn new( + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option<&str>, + ) -> Self { + Self { + availability, + content: content.map(str::to_string), + scope_allowed: true, + paths: Mutex::new(Vec::new()), + } + } + + fn denied() -> Self { + Self { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some("must not be injected".into()), + scope_allowed: false, + paths: Mutex::new(Vec::new()), + } + } + } + + impl WorkspaceClient for SubjectResidentClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + + fn kind(&self) -> &str { + "subject-resident-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.paths.lock().unwrap().push(request.path.clone()); + assert!(request.path.ends_with("/subjektiv/memory")); + let request: server_api::SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); + assert!(matches!( + request.operation, + server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + )); + if !self.scope_allowed { + return Ok(WorkspaceResponse { + status: 403, + body: "subject scope unavailable".into(), + }); + } + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string( + &server_api::SubjektivMemoryBackendResponse::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, + ), + ) + .unwrap(), + }) + } + } + fn resident_client(content: &str) -> Arc { + resident_client_state( + memory::backend::MemoryResidentSummaryAvailability::Ready, + Some(content), + ) + } + + fn resident_client_state( + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option<&str>, + ) -> Arc { use std::io::{Read, Write}; use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); - let content = content.to_string(); + let content = content.map(str::to_string); std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0_u8; 1024]; @@ -852,7 +1110,7 @@ mod tests { "status": "ok", "result": { "kind": "resident_summary", - "availability": "ready", + "availability": availability, "content": content, } }) @@ -989,6 +1247,60 @@ mod tests { ); } + #[test] + fn dedicated_subjektiv_consolidator_uses_subject_settings_and_only_consolidation_tools() { + let workspace = tempfile::tempdir().unwrap(); + let mut manifest = manifest::ProfileResolver::new() + .with_workspace_base(workspace.path()) + .resolve( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + "subjektiv-memory-consolidation", + ), + manifest::ProfileResolveOptions::with_worker_name("subject-consolidator"), + ) + .unwrap() + .manifest; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace".into(), + settings_revision: 1, + language: "English".into(), + }) + .unwrap(); + + assert!(!manifest.feature.memory.profile.enabled); + assert!(!ordinary_subjektiv_features_enabled(&manifest)); + assert!( + MemoryFeatureInstallPlan::prepare( + &manifest, + test_client(), + crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), + ) + .unwrap() + .is_none() + ); + + let plan = SubjektivConsolidationFeatureInstallPlan::prepare( + &manifest, + test_client(), + crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), + ) + .unwrap() + .unwrap(); + assert_eq!( + plan.module.descriptor().id.as_str(), + "builtin:subjektiv-memory-consolidation" + ); + let names = tool_names(plan.module.tools); + assert!(names.contains(&"SubjektivMemoryQuery".to_string())); + assert!(names.contains(&"MemoryApplyCandidate".to_string())); + assert!(!names.contains(&"MemoryQuery".to_string())); + assert!(!names.contains(&"SubjektivRemember".to_string())); + } + #[tokio::test] async fn memory_prompt_contribution_defers_resident_summary_until_loaded() { let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(); @@ -1029,6 +1341,132 @@ mod tests { ); } + fn subject_manifest(workspace_id: &str) -> manifest::WorkerManifest { + let mut manifest = manifest::WorkerManifest::from_toml( + r#" +[worker] +name = "subject-worker" +scope = "main" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/subject-resident-test" +permission = "write" +"#, + ) + .unwrap(); + manifest.feature.subjektiv.profile.enabled = true; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: workspace_id.to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); + manifest + } + + #[tokio::test] + async fn subject_resident_source_preserves_only_current_ready_surface() { + let manifest = subject_manifest("workspace"); + let ready_client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ready, + Some("current subject surface"), + )); + let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) + .unwrap() + .unwrap(); + assert_eq!( + ready.load().await, + SystemPromptContribution::Ready("current subject surface".into()) + ); + assert_eq!( + ready_client.paths.lock().unwrap().clone(), + vec!["/api/w/workspace/subjektiv/memory".to_string()] + ); + + let ready_empty_client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ready, + None, + )); + let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) + .unwrap() + .unwrap(); + assert_eq!( + ready_empty.load().await, + SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) + ); + + for availability in [ + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + memory::backend::MemoryResidentSummaryAvailability::Stale, + memory::backend::MemoryResidentSummaryAvailability::Failed, + ] { + let source = ordinary_subjektiv_resident_summary_source( + &manifest, + Arc::new(SubjectResidentClient::new( + availability, + Some("must not be injected"), + )), + ) + .unwrap() + .unwrap(); + assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + } + } + + #[tokio::test] + async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::denied()); + let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) + .unwrap() + .unwrap(); + + assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + assert_eq!( + client.paths.lock().unwrap().clone(), + vec!["/api/w/workspace/subjektiv/memory".to_string()] + ); + } + + #[test] + fn subject_resident_source_requires_trusted_matching_workspace_config() { + let disabled = manifest::WorkerManifest::from_toml( + r#" +[worker] +name = "ordinary-worker" +scope = "main" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/subject-resident-test" +permission = "write" +"#, + ) + .unwrap(); + assert!( + ordinary_subjektiv_resident_summary_source(&disabled, test_client()) + .unwrap() + .is_none() + ); + + let foreign = subject_manifest("other-workspace"); + assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err()); + } + #[test] fn memory_feature_owns_normal_and_staging_tool_surfaces() { let normal = MemoryToolsFeature::new(test_client(), false); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index d645a1670..01a64eb94 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -49,6 +49,35 @@ enum ExtractionTarget { Subjektiv, } +#[derive(Clone)] +struct LifecycleConfig { + extraction: manifest::MemoryExtractionProfileConfig, + consolidation_request_enabled: bool, + workspace_settings: Option, +} + +impl LifecycleConfig { + fn from_memory(config: manifest::ResolvedMemoryFeatureConfig) -> Self { + Self { + extraction: config.profile.extraction, + consolidation_request_enabled: config.profile.consolidation.request_enabled, + workspace_settings: config.workspace_settings, + } + } + + fn from_subjektiv(config: manifest::ResolvedSubjektivFeatureConfig) -> Self { + Self { + extraction: config.profile.extraction, + consolidation_request_enabled: true, + workspace_settings: config.workspace_settings, + } + } + + fn workspace_settings(&self) -> Option { + self.workspace_settings.clone() + } +} + /// Parent-Worker lifecycle Feature that observes committed runs and schedules /// bounded extraction work. It owns the Memory pointer, audit, restricted /// Internal Worker, and staging disposition; Worker core owns only generic @@ -67,7 +96,7 @@ pub(crate) struct SubjektivLifecycleFeature { #[derive(Clone)] struct MemoryLifecycleTask { - config: manifest::ResolvedMemoryFeatureConfig, + config: LifecycleConfig, target: ExtractionTarget, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, @@ -141,7 +170,7 @@ impl MemoryLifecycleFeature { ) -> Self { Self { task: MemoryLifecycleTask { - config, + config: LifecycleConfig::from_memory(config), target: ExtractionTarget::WorkspaceMemory, capture, extensions, @@ -170,7 +199,7 @@ impl SubjektivLifecycleFeature { workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled || !config.profile.enabled { + if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { return Ok(None); } config @@ -182,23 +211,9 @@ impl SubjektivLifecycleFeature { "subjektiv extraction requires Backend Workspace API authority", )); } - let memory_config = manifest::ResolvedMemoryFeatureConfig { - profile: manifest::MemoryFeatureProfileConfig { - enabled: true, - staging_tools: false, - resident: manifest::MemoryResidentProfileConfig { - inject_summary: false, - }, - extraction: config.profile.extraction, - consolidation: manifest::MemoryConsolidationProfileConfig { - request_enabled: false, - }, - }, - workspace_settings: config.workspace_settings, - }; Ok(Some(Self { task: MemoryLifecycleTask { - config: memory_config, + config: LifecycleConfig::from_subjektiv(config), target: ExtractionTarget::Subjektiv, capture, extensions, @@ -282,10 +297,10 @@ impl MemoryLifecycleTask { fn extraction_manifest(&self) -> WorkerManifest { let mut manifest = self.manifest.clone(); - if let Some(model) = self.config.profile.extraction.model.clone() { + if let Some(model) = self.config.extraction.model.clone() { manifest.model = model; } - if let Some(reasoning) = self.config.profile.extraction.reasoning.clone() { + if let Some(reasoning) = self.config.extraction.reasoning.clone() { manifest.engine.reasoning = Some(reasoning); } manifest @@ -301,14 +316,13 @@ impl MemoryLifecycleTask { memory::audit::AuditWorker::MemoryExtract, memory::audit::AuditTrigger::TokenThreshold, self.config - .profile .extraction .model .as_ref() .or(Some(&self.manifest.model)) .map(model_audit_from_manifest), ) - .with_memory_settings(&self.config); + .with_workspace_settings(self.config.workspace_settings.as_ref()); let audit = if self.target == ExtractionTarget::Subjektiv { audit.without_legacy_backend() } else { @@ -364,7 +378,6 @@ impl MemoryLifecycleTask { }; let Some(threshold) = self .config - .profile .extraction .threshold .filter(|threshold| *threshold > 0) @@ -470,7 +483,7 @@ impl MemoryLifecycleTask { capture.session_id.clone(), ), }; - let client = if let Some(model) = self.config.profile.extraction.model.as_ref() { + let client = if let Some(model) = self.config.extraction.model.as_ref() { match crate::model_client::build_client(model) { Ok(client) => client, Err(error) => { @@ -534,7 +547,6 @@ impl MemoryLifecycleTask { cache_key: Some(capture.segment_id.clone()), max_turns: self .config - .profile .extraction .worker_max_turns .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), @@ -710,7 +722,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { context: BackgroundTaskContext, cancellation: BackgroundTaskCancellation, ) -> Result<(), HookError> { - let extraction = if self.config.profile.extraction.enabled { + let extraction = if self.config.extraction.enabled { self.run_extraction(context.clone(), cancellation.clone()) .await } else { @@ -719,9 +731,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { if !cancellation.is_cancelled() { context.generation_fence.ensure_current()?; match self.target { - ExtractionTarget::WorkspaceMemory - if self.config.profile.consolidation.request_enabled => - { + ExtractionTarget::WorkspaceMemory if self.config.consolidation_request_enabled => { self.request_consolidation(false).await; } ExtractionTarget::Subjektiv => { @@ -770,7 +780,7 @@ impl MemoryLifecycleTask { memory::audit::AuditTrigger::StagingBacklog, Some(model_audit_from_manifest(&self.manifest.model)), ) - .with_memory_settings(&self.config); + .with_workspace_settings(self.config.workspace_settings.as_ref()); let operation = memory::backend::MemoryConsolidateStagingOperation { force: false }; let request = if subjektiv { self.workspace_client @@ -981,15 +991,15 @@ impl WorkerAuditBase { self } - fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self { - self.memory_settings = - config - .workspace_settings() - .map(|snapshot| memory::audit::MemorySettingsAudit { - workspace_id: snapshot.workspace_id, - settings_revision: snapshot.settings_revision, - language: snapshot.language, - }); + fn with_workspace_settings( + mut self, + settings: Option<&manifest::WorkspaceMemorySettingsSnapshot>, + ) -> Self { + self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit { + workspace_id: snapshot.workspace_id.clone(), + settings_revision: snapshot.settings_revision, + language: snapshot.language.clone(), + }); self } @@ -1407,7 +1417,7 @@ permission = "write" Ok(true) }); MemoryLifecycleTask { - config: test_config(), + config: LifecycleConfig::from_memory(test_config()), target: ExtractionTarget::WorkspaceMemory, capture: capture_handle, extensions, @@ -1483,6 +1493,35 @@ permission = "write" assert!(feature.is_none()); } + #[test] + fn subjektiv_without_extraction_installs_no_session_lifecycle() { + let capture = CommittedSessionCaptureHandle::new(|| { + panic!("subjektiv without extraction must not capture a Session") + }); + let extensions = SessionExtensionHandle::new(|_, _, _| { + panic!("subjektiv without extraction must not write a pointer") + }); + let mut config = manifest::ResolvedSubjektivFeatureConfig::default(); + config.profile.enabled = true; + config.profile.extraction.enabled = false; + + let feature = SubjektivLifecycleFeature::from_resolved_config( + true, + config, + capture, + extensions, + crate::worker::marker_workspace_client(None, "extraction-disabled"), + test_manifest(), + Box::new(ScriptClient::new(Vec::new())), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + WorkerWorkspaceContext::no_workspace(), + None, + ) + .unwrap(); + + assert!(feature.is_none()); + } + #[test] fn child_host_suppresses_enabled_subjektiv_lifecycle_without_capture_side_effects() { let capture = CommittedSessionCaptureHandle::new(|| { @@ -1536,12 +1575,11 @@ permission = "write" event_tx, workspace_client, ); - task.config.profile.extraction.model = Some(manifest::ModelManifest { + task.config.extraction.model = Some(manifest::ModelManifest { ref_: Some("codex-oauth/gpt-5.6-luna".to_string()), ..Default::default() }); - task.config.profile.extraction.reasoning = - Some(ReasoningControl::Effort(ReasoningEffort::Medium)); + task.config.extraction.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium)); let manifest = task.extraction_manifest(); assert_eq!( @@ -1658,7 +1696,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let writes = extension_writes.lock().unwrap(); @@ -1711,7 +1749,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let writes = extension_writes.lock().unwrap(); @@ -1747,7 +1785,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; let registry = start_background_task(task); tokio::time::timeout(Duration::from_secs(5), async { while !workspace_client.session_started.load(Ordering::Acquire) { @@ -1797,7 +1835,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; assert!(extension_writes.lock().unwrap().is_empty()); @@ -1914,7 +1952,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; let registry = start_background_task(task); tokio::time::timeout(Duration::from_secs(5), async { while calls.load(Ordering::SeqCst) == 0 { @@ -2011,7 +2049,7 @@ permission = "write" event_tx, workspace_client.clone(), ); - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let requests = workspace_client.requests.lock().unwrap(); @@ -2041,8 +2079,8 @@ permission = "write" event_tx, workspace_client.clone(), ); - task.config.profile.extraction.enabled = false; - task.config.profile.consolidation.request_enabled = true; + task.config.extraction.enabled = false; + task.config.consolidation_request_enabled = true; run_background_task(task).await; assert!(extension_writes.lock().unwrap().is_empty()); diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index 730b60c7e..a314628a7 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -14,7 +14,9 @@ use crate::feature::background::{ BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger, FeatureBackgroundTask, }; -use crate::feature::builtin::memory::WorkspaceMemoryBackendError; +use crate::feature::builtin::memory::{ + WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile, +}; use crate::feature::builtin::memory_surface_output::{ MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition, }; @@ -98,19 +100,15 @@ impl SubjektivSurfaceLifecycleFeature { prompts: Arc>, workspace_context: WorkerWorkspaceContext, ) -> std::io::Result> { - let dedicated = manifest.profile.as_ref().is_some_and(|snapshot| { - matches!( - &snapshot.source, - manifest::ProfileSource::Registry { - source: manifest::ProfileRegistrySource::Builtin, - name, - .. - } if name == "subjektiv-memory-consolidation" - ) - }); + let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest); if !lifecycle_enabled || !dedicated { return Ok(None); } + manifest + .feature + .subjektiv + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -260,13 +258,13 @@ impl SubjektivSurfaceLifecycleTask { let language = self .manifest .feature - .memory + .subjektiv .workspace_settings() .map(|settings| settings.language) .ok_or_else(|| { SurfaceEditorFailure::editor(HookError::new( HookErrorCategory::Internal, - "surface editor requires bound Workspace Memory settings", + "surface editor requires bound subjektiv Workspace settings", )) })?; let system_prompt = self @@ -721,10 +719,10 @@ permission = "write" "#, ) .unwrap(); - manifest.feature.memory.profile.enabled = true; + manifest.feature.subjektiv.profile.enabled = true; manifest .feature - .memory + .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".into(), settings_revision: 1, diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 75d6e8ee4..ad49fd8fb 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1044,16 +1044,25 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() { ProfileResolveOptions::with_worker_name("orchestrator-worker"), ) .unwrap(); + let settings = manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-test".to_string(), + settings_revision: 1, + language: "English".to_string(), + }; if resolved.manifest.feature.memory.enabled() { resolved .manifest .feature .memory - .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-test".to_string(), - settings_revision: 1, - language: "English".to_string(), - }) + .bind_workspace_settings(settings.clone()) + .unwrap(); + } + if resolved.manifest.feature.subjektiv.enabled() { + resolved + .manifest + .feature + .subjektiv + .bind_workspace_settings(settings) .unwrap(); } let workspace_context = diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e8e3c16ee..f7db02875 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -20,8 +20,8 @@ use config_source::ConfigTreeSnapshot; use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ - MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, - MemoryConsolidateStagingOperation, MemoryConsolidationOutput, + MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, + MemoryConsolidationOutput, }; use protocol::Segment; use protocol::stream::{decode_method, encode_event}; @@ -5433,6 +5433,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::memory_document( service.clone(), )) + .merge(server_api::server_api_axum::legacy_memory_reset( + service.clone(), + )) .merge(server_api::server_api_axum::memory_staging_list( service.clone(), )) @@ -5443,9 +5446,24 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_create( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_list( + service.clone(), + )) .merge(server_api::server_api_axum::subjektiv_subject_get( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_resident_surface( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_list( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_revisions( + service.clone(), + )) .merge(server_api::server_api_axum::subjektiv_subject_retire( service.clone(), )) @@ -5464,6 +5482,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_session_backend( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_memory_consolidation( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -6984,6 +7005,24 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn legacy_memory_reset( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::LegacyMemoryResetRequest, + ) -> std::result::Result + { + scoped_reset_legacy_memory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn memory_staging_list( &self, workspace_id: String, @@ -7056,6 +7095,22 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_list( + &self, + workspace_id: String, + query: server_api::SubjektivSubjectListQuery, + ) -> std::result::Result + { + scoped_list_subjektiv_subjects( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_subject_get( &self, workspace_id: String, @@ -7074,6 +7129,92 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_resident_surface( + &self, + workspace_id: String, + subject_id: String, + ) -> std::result::Result< + server_api::SubjektivResidentSurfaceResponse, + server_api::RepositoryApiError, + > { + scoped_get_subjektiv_resident_surface( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_list( + &self, + workspace_id: String, + subject_id: String, + query: server_api::SubjektivMemoryListQuery, + ) -> std::result::Result + { + scoped_list_subjektiv_memories( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + Query(query.into()), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_detail( + &self, + workspace_id: String, + subject_id: String, + memory_id: String, + query: server_api::SubjektivMemoryDetailQuery, + ) -> std::result::Result + { + scoped_get_subjektiv_memory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id, + subject_id, + memory_id, + }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_revisions( + &self, + workspace_id: String, + subject_id: String, + memory_id: String, + query: server_api::SubjektivMemoryRevisionsQuery, + ) -> std::result::Result< + server_api::SubjektivMemoryListRevisionsResponse, + server_api::RepositoryApiError, + > { + scoped_list_subjektiv_memory_revisions( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id, + subject_id, + memory_id, + }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_subject_retire( &self, workspace_id: String, @@ -10435,6 +10576,13 @@ struct ScopedSubjektivSubjectPath { subject_id: String, } +#[derive(Debug, Clone, Deserialize)] +struct ScopedSubjektivMemoryPath { + workspace_id: String, + subject_id: String, + memory_id: String, +} + #[derive(Debug, Deserialize)] struct ScopedFlowPath { workspace_id: String, @@ -16540,6 +16688,35 @@ async fn scoped_get_memory_document( })) } +async fn scoped_reset_legacy_memory( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Legacy Workspace Memory reset", + ) + .await?; + let expected = format!("RESET LEGACY MEMORY {}", path.workspace_id); + if request.confirmation != expected { + return Err(Error::InvalidInput(format!( + "legacy Memory reset confirmation must exactly equal `{expected}`" + )) + .into()); + } + let counts = api.store.reset_legacy_memory(&path.workspace_id)?; + Ok(Json(server_api::LegacyMemoryResetResponse { + workspace_id: path.workspace_id, + documents_deleted: counts.documents, + staging_records_deleted: counts.staging_records, + staging_resolutions_deleted: counts.staging_resolutions, + })) +} + async fn scoped_list_memory_staging( State(api): State, AxumPath(path): AxumPath, @@ -16557,16 +16734,11 @@ async fn scoped_list_memory_staging( async fn scoped_memory_backend_operation( State(api): State, AxumPath(path): AxumPath, - context: server_api::ServerRequestContext, + _context: server_api::ServerRequestContext, Json(operation): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let result = match operation { - MemoryBackendOperation::ResidentSummary(_) => { - execute_scoped_resident_summary(&api, &context) - } - operation => execute_memory_backend_operation_with_authority(&api.authority, operation), - }; + let result = execute_memory_backend_operation_with_authority(&api.authority, operation); let response = match result { Ok(result) => MemoryBackendHttpResponse::Ok { result }, Err(error) => MemoryBackendHttpResponse::Error { @@ -16576,48 +16748,6 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } -fn execute_scoped_resident_summary( - api: &WorkspaceApi, - context: &server_api::ServerRequestContext, -) -> crate::Result { - let Some(source) = context.runtime_source.as_ref() else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let Some(worker_id) = source.worker_id.as_deref() else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); - let Some(lease) = api - .store - .require_current_worker_singleton_owner(&api.config.workspace_id, &worker)? - else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let Some(subject_id) = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let resident = - crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) - .map_err(|error| Error::Store(error.to_string()))? - .resident_surface(subject_id) - .map_err(|error| Error::Store(error.to_string()))?; - Ok(MemoryBackendOperationResult::ResidentSummary( - resident_summary_output(resident), - )) -} - fn resident_summary_output( resident: crate::subjektiv::ResidentSurface, ) -> memory::backend::MemoryResidentSummaryOutput { @@ -16852,6 +16982,34 @@ async fn scoped_create_subjektiv_subject( )) } +async fn scoped_list_subjektiv_subjects( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let limit = query.limit.unwrap_or(20); + if limit == 0 || limit > server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT { + return Err(Error::InvalidInput(format!( + "subject list limit must be within 1..={}", + server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT + )) + .into()); + } + let page = open_subjektiv_store(&api)? + .list_subjects(limit) + .map_err(subjektiv_store_error)?; + Ok(Json(server_api::SubjektivSubjectListResponse { + limit, + items: page + .items + .into_iter() + .map(|subject| subjektiv_subject_response(&api, subject)) + .collect(), + has_more: page.has_more, + })) +} + async fn scoped_get_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, @@ -16864,6 +17022,123 @@ async fn scoped_get_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } +fn require_subjektiv_subject( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, +) -> ApiResult<()> { + if store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .is_none() + { + return Err(Error::SubjektivSubjectNotFound(subject_id.to_string()).into()); + } + Ok(()) +} + +async fn scoped_get_subjektiv_resident_surface( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + let resident = store + .resident_surface(&path.subject_id) + .map_err(subjektiv_store_error)?; + let availability = match resident.availability { + crate::subjektiv::SurfaceAvailability::Ungenerated => { + server_api::SubjektivResidentSurfaceAvailability::Ungenerated + } + crate::subjektiv::SurfaceAvailability::Stale => { + server_api::SubjektivResidentSurfaceAvailability::Stale + } + crate::subjektiv::SurfaceAvailability::Failed => { + server_api::SubjektivResidentSurfaceAvailability::Failed + } + crate::subjektiv::SurfaceAvailability::Ready => { + server_api::SubjektivResidentSurfaceAvailability::Ready + } + }; + let snapshot = resident + .snapshot + .map(|snapshot| server_api::SubjektivResidentSurfaceSnapshot { + snapshot_id: snapshot.id, + body_md: snapshot.body_md, + memory_refs: snapshot + .memory_refs + .into_iter() + .map(|reference| server_api::SubjektivMemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + built_from_store_revision: snapshot.built_from_store_revision, + created_at: snapshot.created_at, + }); + Ok(Json(server_api::SubjektivResidentSurfaceResponse { + subject_id: path.subject_id, + availability, + snapshot, + })) +} + +async fn scoped_list_subjektiv_memories( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_query( + &store, + &path.subject_id, + query, + )?)) +} + +async fn scoped_get_subjektiv_memory( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_read( + &store, + &path.subject_id, + server_api::SubjektivMemoryReadRequest { + memory_id: path.memory_id, + revision: query.revision, + offset: query.offset, + byte_offset: query.byte_offset, + limit: query.limit, + evidence_cursor: query.evidence_cursor, + }, + )?)) +} + +async fn scoped_list_subjektiv_memory_revisions( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_list_revisions( + &store, + &path.subject_id, + server_api::SubjektivMemoryListRevisionsRequest { + memory_id: path.memory_id, + limit: query.limit, + cursor: query.cursor, + }, + )?)) +} + async fn scoped_retire_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, @@ -16871,7 +17146,7 @@ async fn scoped_retire_subjektiv_subject( validate_workspace_scope(&api, &path.workspace_id)?; let subject = open_subjektiv_store(&api)? .retire_subject(&path.subject_id) - .map_err(|error| Error::InvalidInput(error.to_string()))?; + .map_err(subjektiv_store_error)?; // Retirement deliberately does not stop or remove the current Worker. The // Worker lease and subject lifecycle are independent authorities. Ok(Json(subjektiv_subject_response(&api, subject))) @@ -18357,6 +18632,19 @@ async fn scoped_subjektiv_memory_backend( let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; let store = open_subjektiv_store(&api)?; let response = match request.operation { + server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "resident surface read", + )?; + let resident = store + .resident_surface(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ResidentSummary(resident_summary_output( + resident, + )) + } server_api::SubjektivMemoryBackendOperation::Query(input) => { server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( &store, @@ -19738,9 +20026,12 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { | crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => { Error::RepositoryConflict(format!("candidate_decision_conflict: {error}")) } - crate::subjektiv::SubjektivError::MemoryNotFound(_) => { - Error::InvalidInput(format!("memory_not_found: {error}")) + crate::subjektiv::SubjektivError::SubjectNotFound(subject_id) => { + Error::SubjektivSubjectNotFound(subject_id) } + crate::subjektiv::SubjektivError::MemoryNotFound(memory_id) => Error::InvalidInput( + format!("memory_not_found: Memory `{memory_id}` was not found"), + ), crate::subjektiv::SubjektivError::Storage(_) => Error::Store(error.to_string()), _ => Error::InvalidInput(error.to_string()), } @@ -31302,7 +31593,14 @@ fn api_error_status(error: &Error) -> StatusCode { | Error::RuntimeBindingRevisionConflict { .. } | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, + Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, + Error::InvalidInput(message) + if message.starts_with("memory_not_found:") + || message.starts_with("memory_revision_not_found:") => + { + StatusCode::NOT_FOUND + } + Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { StatusCode::BAD_REQUEST } @@ -38007,6 +38305,398 @@ mod tests { assert_eq!(chunks, content); } + #[tokio::test] + async fn legacy_memory_reset_requires_owner_confirmation_and_preserves_subjektiv() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + api.store + .upsert_memory_document(&crate::store::MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Legacy Memory\n\nRetained until explicit reset.\n".to_string(), + created_at: "2026-10-02T00:00:00Z".to_string(), + updated_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + api.store + .upsert_memory_staging_record(&crate::store::MemoryStagingRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + candidate_id: "legacy-pending".to_string(), + raw_json: r#"{"claim":"pending"}"#.to_string(), + source_path: None, + imported_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + api.store + .insert_memory_staging_resolution(&crate::store::MemoryStagingResolutionRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + candidate_id: "legacy-resolved".to_string(), + action: "discarded".to_string(), + reason: "fixture".to_string(), + affected_refs_json: "[]".to_string(), + staging_raw_json: r#"{"claim":"resolved"}"#.to_string(), + source_path: None, + imported_at: "2026-10-02T00:00:00Z".to_string(), + resolved_at: "2026-10-02T00:01:00Z".to_string(), + }) + .unwrap(); + + let subjektiv = open_subjektiv_store(&api).unwrap(); + let subject = subjektiv + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let memory = subjektiv + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Preserve feature Memory", + "This record is outside the legacy reset scope.", + "Proves reset isolation", + "fixture", + ), + ) + .unwrap(); + + let incorrect = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: "RESET LEGACY MEMORY wrong-workspace".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(incorrect.into_response().status(), StatusCode::BAD_REQUEST); + assert!( + api.store + .get_memory_document(TEST_WORKSPACE_ID) + .unwrap() + .is_some() + ); + + let mut non_owner = test_owner_actor(); + non_owner.account_id = "another-account".to_string(); + let forbidden = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(non_owner), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap_err(); + assert_eq!(forbidden.into_response().status(), StatusCode::FORBIDDEN); + + let Json(reset) = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap(); + assert_eq!(reset.documents_deleted, 1); + assert_eq!(reset.staging_records_deleted, 1); + assert_eq!(reset.staging_resolutions_deleted, 1); + assert!( + api.store + .get_memory_document(TEST_WORKSPACE_ID) + .unwrap() + .is_none() + ); + assert_eq!( + subjektiv + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .revision, + 1 + ); + + let Json(repeated) = scoped_reset_legacy_memory( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap(); + assert_eq!(repeated.documents_deleted, 0); + assert_eq!(repeated.staging_records_deleted, 0); + assert_eq!(repeated.staging_resolutions_deleted, 0); + } + + #[tokio::test] + async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let store = open_subjektiv_store(&api).unwrap(); + let first_subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let second_subject = store + .create_subject(crate::subjektiv::SubjectRole::new("reviewer").unwrap()) + .unwrap(); + + let invalid_limit = scoped_list_subjektiv_subjects( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }), + ) + .await + .unwrap_err(); + assert_eq!( + invalid_limit.into_response().status(), + StatusCode::BAD_REQUEST + ); + + let Json(subjects) = scoped_list_subjektiv_subjects( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }), + ) + .await + .unwrap(); + assert_eq!(subjects.items.len(), 1); + assert!(subjects.has_more); + + let missing = scoped_get_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: "missing-subject".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND); + assert_eq!(store.list_subjects(100).unwrap().items.len(), 2); + + let Json(subject_detail) = scoped_get_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + ) + .await + .unwrap(); + assert_eq!(subject_detail.id, first_subject.id); + let Json(ungenerated) = scoped_get_subjektiv_resident_surface( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + ) + .await + .unwrap(); + assert_eq!( + ungenerated.availability, + server_api::SubjektivResidentSurfaceAvailability::Ungenerated + ); + assert!(ungenerated.snapshot.is_none()); + + let memory = store + .create_memory( + &first_subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep exact revisions", + "first body", + "Supports historical reads", + "initial", + ), + ) + .unwrap(); + let revised = store + .revise_memory( + &first_subject.id, + &memory.id, + 1, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep exact revisions", + "second body", + "Supports historical reads", + "correction", + ), + ) + .unwrap(); + let other_memory = store + .create_memory( + &second_subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Keep subjects isolated", + "other body", + "Prevents cross-subject reads", + "initial", + ), + ) + .unwrap(); + + let Json(memories) = scoped_list_subjektiv_memories( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + Query(server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: None, + limit: Some(1), + cursor: None, + }), + ) + .await + .unwrap(); + assert_eq!(memories.items.len(), 1); + assert_eq!(memories.items[0].id, memory.id); + assert_eq!(memories.items[0].revision, 2); + + let missing_memory = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: "missing-memory".to_string(), + }), + Query(server_api::SubjektivMemoryDetailQuery::default()), + ) + .await + .unwrap_err(); + assert_eq!( + missing_memory.into_response().status(), + StatusCode::NOT_FOUND + ); + + let missing_revision = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryDetailQuery { + revision: Some(99), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!( + missing_revision.into_response().status(), + StatusCode::NOT_FOUND + ); + + let cross_subject = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: other_memory.id, + }), + Query(server_api::SubjektivMemoryDetailQuery::default()), + ) + .await + .unwrap_err(); + assert_eq!( + cross_subject.into_response().status(), + StatusCode::FORBIDDEN + ); + + let Json(historical) = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryDetailQuery { + revision: Some(1), + ..Default::default() + }), + ) + .await + .unwrap(); + assert_eq!(historical.revision, 1); + assert_eq!(historical.current_revision, 2); + assert_eq!(historical.body_md, "first body"); + + let Json(revisions) = scoped_list_subjektiv_memory_revisions( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryRevisionsQuery { + limit: Some(1), + cursor: None, + }), + ) + .await + .unwrap(); + assert_eq!(revisions.current_revision, 2); + assert_eq!(revisions.items[0].revision, 2); + assert!(revisions.has_more); + + let generation = store.prepare_surface_generation(&first_subject.id).unwrap(); + let expected_ref = crate::subjektiv::MemoryRevisionRef { + memory_id: revised.id.clone(), + revision: revised.revision, + }; + store + .publish_surface_generation( + &first_subject.id, + &generation.id, + vec![crate::subjektiv::SurfacePoint { + body_md: "- Keep exact revisions".to_string(), + memory_refs: vec![expected_ref.clone()], + }], + ) + .unwrap(); + let Json(surface) = scoped_get_subjektiv_resident_surface( + State(api), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id, + }), + ) + .await + .unwrap(); + assert_eq!( + surface.availability, + server_api::SubjektivResidentSurfaceAvailability::Ready + ); + let snapshot = surface.snapshot.unwrap(); + assert_eq!(snapshot.memory_refs.len(), 1); + assert_eq!(snapshot.memory_refs[0].memory_id, expected_ref.memory_id); + assert_eq!(snapshot.memory_refs[0].revision, expected_ref.revision); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); @@ -38064,6 +38754,58 @@ mod tests { origin: None, transport_headers: Vec::new(), }; + api.store + .upsert_memory_document(&MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Legacy sentinel\n".to_string(), + created_at: "2026-10-02T00:00:00Z".to_string(), + updated_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + let Json(legacy_resident) = scoped_memory_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(MemoryBackendOperation::ResidentSummary(Default::default())), + ) + .await + .unwrap(); + assert!(matches!( + legacy_resident, + MemoryBackendHttpResponse::Ok { + result: memory::backend::MemoryBackendOperationResult::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some(ref body), + } + ) + } if body == "# Legacy sentinel\n" + )); + let Json(resident) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentSummary( + Default::default(), + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + resident, + server_api::SubjektivMemoryBackendResponse::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + } + ) + )); let operation = memory::backend::MemoryStageCandidateOperation { source: memory::schema::SourceRef { segment_id: "segment-1".to_string(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index d2e4b76f9..4d963b05e 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1035,6 +1035,13 @@ pub struct MemoryStagingResolutionRecord { pub resolved_at: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LegacyMemoryResetCounts { + pub documents: usize, + pub staging_records: usize, + pub staging_resolutions: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FlowSourceRecord { pub workspace_id: String, @@ -1450,6 +1457,10 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, limit: usize, ) -> Result>; + /// Atomically removes only the legacy Workspace Memory document and legacy + /// staging rows. Subjektiv Feature storage and all other Workspace state are + /// outside this transaction's deletion set. + fn reset_legacy_memory(&self, workspace_id: &str) -> Result; fn upsert_account(&self, record: &AccountRecord) -> Result<()>; fn get_account(&self, account_id: &str) -> Result>; @@ -5890,6 +5901,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn reset_legacy_memory(&self, workspace_id: &str) -> Result { + validate_identifier("workspace_id", workspace_id)?; + self.with_conn_mut(|connection| { + let transaction = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + // Resolutions retain copies of staging rows, so remove them first; + // pending staging follows, then the singleton legacy document. + let staging_resolutions = transaction.execute( + "DELETE FROM memory_staging_resolutions WHERE workspace_id = ?1", + params![workspace_id], + )?; + let staging_records = transaction.execute( + "DELETE FROM memory_staging_records WHERE workspace_id = ?1", + params![workspace_id], + )?; + let documents = transaction.execute( + "DELETE FROM workspace_memory_documents WHERE workspace_id = ?1", + params![workspace_id], + )?; + transaction.commit()?; + Ok(LegacyMemoryResetCounts { + documents, + staging_records, + staging_resolutions, + }) + }) + } + fn upsert_account(&self, record: &AccountRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -20139,6 +20178,171 @@ INSERT INTO worker_registry ( ); } + #[tokio::test] + async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + for (workspace_id, display_name) in [ + ("local-dev", "Local Dev"), + ("other-workspace", "Other Workspace"), + ] { + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: workspace_id.to_string(), + owner_account_id: "owner-account".to_string(), + display_name: display_name.to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + store + .ensure_memory_document(workspace_id, "# Legacy Memory\n", "2") + .unwrap(); + store + .upsert_memory_staging_record(&MemoryStagingRecord { + workspace_id: workspace_id.to_string(), + candidate_id: format!("candidate-{workspace_id}"), + raw_json: r#"{"claim":"legacy"}"#.to_string(), + source_path: None, + imported_at: "3".to_string(), + }) + .unwrap(); + store + .insert_memory_staging_resolution(&MemoryStagingResolutionRecord { + workspace_id: workspace_id.to_string(), + candidate_id: format!("resolved-{workspace_id}"), + action: "discarded".to_string(), + reason: "fixture".to_string(), + affected_refs_json: "[]".to_string(), + staging_raw_json: r#"{"claim":"resolved"}"#.to_string(), + source_path: None, + imported_at: "3".to_string(), + resolved_at: "4".to_string(), + }) + .unwrap(); + } + + let unrelated_counts = |store: &SqliteWorkspaceStore| { + store + .with_conn(|connection| { + let mut tables = connection.prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + )?; + let names = tables + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + drop(tables); + let mut counts = std::collections::BTreeMap::new(); + for name in names { + if matches!( + name.as_str(), + "workspace_memory_documents" + | "memory_staging_records" + | "memory_staging_resolutions" + ) { + continue; + } + let quoted = name.replace('"', "\"\""); + let count = connection.query_row( + &format!("SELECT COUNT(*) FROM \"{quoted}\""), + [], + |row| row.get::<_, i64>(0), + )?; + counts.insert(name, count); + } + Ok(counts) + }) + .unwrap() + }; + let before = unrelated_counts(&store); + assert_eq!( + store + .get_workspace_memory_settings("local-dev") + .unwrap() + .language, + "English" + ); + + // A failure after resolution deletion must roll the entire operation back. + store + .with_conn(|connection| { + connection.execute_batch( + "CREATE TRIGGER fail_legacy_memory_reset + BEFORE DELETE ON memory_staging_records + WHEN OLD.workspace_id = 'local-dev' + BEGIN SELECT RAISE(ABORT, 'reset failure fixture'); END;", + )?; + Ok(()) + }) + .unwrap(); + assert!(store.reset_legacy_memory("local-dev").is_err()); + assert!(store.get_memory_document("local-dev").unwrap().is_some()); + assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 1); + assert_eq!( + store + .list_memory_staging_resolutions("local-dev", 10) + .unwrap() + .len(), + 1 + ); + store + .with_conn(|connection| { + connection.execute_batch("DROP TRIGGER fail_legacy_memory_reset")?; + Ok(()) + }) + .unwrap(); + + assert_eq!( + store.reset_legacy_memory("local-dev").unwrap(), + LegacyMemoryResetCounts { + documents: 1, + staging_records: 1, + staging_resolutions: 1, + } + ); + assert_eq!( + store.reset_legacy_memory("local-dev").unwrap(), + LegacyMemoryResetCounts::default() + ); + assert!(store.get_memory_document("local-dev").unwrap().is_none()); + assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 0); + assert!( + store + .list_memory_staging_resolutions("local-dev", 10) + .unwrap() + .is_empty() + ); + + assert!( + store + .get_memory_document("other-workspace") + .unwrap() + .is_some() + ); + assert_eq!( + store + .count_memory_staging_records("other-workspace") + .unwrap(), + 1 + ); + assert_eq!( + store + .list_memory_staging_resolutions("other-workspace", 10) + .unwrap() + .len(), + 1 + ); + assert_eq!(unrelated_counts(&store), before); + assert_eq!( + store + .get_workspace_memory_settings("local-dev") + .unwrap() + .language, + "English" + ); + } + #[tokio::test] async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 767f6bef4..467ddc862 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -141,6 +141,12 @@ pub struct SubjectRecord { pub updated_at: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectPage { + pub items: Vec, + pub has_more: bool, +} + /// Immutable host-recorded history that attributes one durable Session to the /// subject and Worker that produced it. This is archival provenance only: it is /// deliberately not a current-Worker link or execution ownership record. @@ -1085,6 +1091,35 @@ impl SubjektivStore { }) } + /// Lists one deterministic bounded page of subjects. Ordering uses immutable + /// creation time with the host-issued subject id as a total-order tie breaker. + pub fn list_subjects(&self, limit: usize) -> Result { + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject list limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let mut statement = connection.prepare( + "SELECT record_json FROM subjects + ORDER BY created_at ASC, subject_id ASC + LIMIT ?1", + )?; + let rows = statement + .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; + let mut items = Vec::new(); + for row in rows { + items.push(parse_subject(&row?)?); + } + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectPage { items, has_more }) + }) + } + /// Subject retirement is distinct from stopping any current Worker. It does /// not edit or delete Memory and cannot be reversed by this baseline schema. pub fn retire_subject(&self, subject_id: &str) -> Result { @@ -3506,6 +3541,155 @@ mod tests { (manager, workspace, store) } + #[test] + fn subject_listing_is_deterministic_bounded_and_read_only() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let mut expected = vec![ + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + ]; + expected.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let page = store.list_subjects(2).unwrap(); + assert_eq!(page.items, expected[..2]); + assert!(page.has_more); + assert!(store.list_subjects(0).is_err()); + assert!(store.list_subjects(101).is_err()); + + assert!(store.subject("missing-subject").unwrap().is_none()); + let complete = store.list_subjects(3).unwrap(); + assert_eq!(complete.items, expected); + assert!(!complete.has_more); + } + + #[test] + fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let session = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T09:00:00.000Z", + ); + + let (first_candidate, stored_session) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + session.clone(), + ) + .unwrap(); + assert_eq!(stored_session, session); + assert_eq!( + first_candidate.source_refs[0].session_id.as_deref(), + Some("session-1") + ); + + let create_request = CandidateDecisionRequest { + request_id: "decision-create-1".into(), + candidate_id: first_candidate.id.clone(), + reason: "adopt the attributed candidate".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Create, + draft: draft( + "Keep the first confirmed rule", + "initial attributed decision", + ), + }, + }; + let created = store + .decide_candidate(&subject.id, create_request.clone()) + .unwrap(); + let first_revision = created.memory.unwrap(); + assert_eq!(first_revision.revision, 1); + assert_eq!( + first_revision.source_candidate_ids, + [first_candidate.id.clone()] + ); + assert_eq!(created.resolution.affected_memory.len(), 1); + assert_eq!(created.resolution.affected_memory[0].revision, 1); + + // Exact decision retries return the original receipt rather than creating + // a second revision or resolution. + let replayed = store.decide_candidate(&subject.id, create_request).unwrap(); + assert_eq!(replayed.store_revision, created.store_revision); + assert_eq!(replayed.memory.as_ref().unwrap().revision, 1); + + let (second_candidate, _) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-2", "workspace-a"), + session, + ) + .unwrap(); + let revised = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-revise-2".into(), + candidate_id: second_candidate.id.clone(), + reason: "apply the attributed correction".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: first_revision.id.clone(), + expected_revision: 1, + }, + draft: draft("Keep the corrected confirmed rule", "attributed correction"), + }, + }, + ) + .unwrap(); + let second_revision = revised.memory.unwrap(); + assert_eq!(second_revision.revision, 2); + assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]); + + let historical = store + .scoped_memory_revision(&subject.id, &first_revision.id, 1) + .unwrap() + .unwrap(); + assert_eq!(historical.source_candidate_ids, [first_candidate.id]); + assert_eq!( + store + .session_attribution("session-1") + .unwrap() + .unwrap() + .subject_id, + subject.id + ); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.materials.len(), 1); + assert_eq!(generation.materials[0].memory_id, second_revision.id); + assert_eq!(generation.materials[0].revision, 2); + let expected_ref = MemoryRevisionRef { + memory_id: second_revision.id.clone(), + revision: second_revision.revision, + }; + let published = store + .publish_surface_generation( + &subject.id, + &generation.id, + vec![SurfacePoint { + body_md: "- Keep the corrected confirmed rule".into(), + memory_refs: vec![expected_ref.clone()], + }], + ) + .unwrap(); + assert_eq!(published.memory_refs, [expected_ref.clone()]); + assert_eq!(published.built_from_store_revision, revised.store_revision); + + let resident = store.resident_surface(&subject.id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Ready); + assert_eq!(resident.snapshot.unwrap().memory_refs, [expected_ref]); + } + #[test] fn versioned_json_roundtrips_and_rejects_invalid_shapes() { let staging = candidate("subject-1", "candidate-1", "workspace-a"); diff --git a/docs/README.md b/docs/README.md index c6a863e0d..00c0e123f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority. 10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection. -11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. +11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — deprecated single-document Workspace Memory authority and its separation from subject-scoped subjektiv Memory. 12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。 @@ -30,6 +30,9 @@ It is not a dumping ground for external research, old plans, API inventories, or 22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. 23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。 +25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、revision、candidate consolidation境界。 +26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。 +27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。 ## What belongs here diff --git a/docs/design/memory-knowledge.md b/docs/design/memory-knowledge.md index fbf2726d4..88d6e7842 100644 --- a/docs/design/memory-knowledge.md +++ b/docs/design/memory-knowledge.md @@ -1,17 +1,23 @@ -# Workspace Memory authority +# Legacy Workspace Memory authority -Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths. +The single-document Workspace Memory product is a **legacy/deprecated** compatibility surface. Its document, staging candidates, and staging resolutions are durable Workspace context owned by the Workspace Server control-plane database. It is separate from subject-scoped subjektiv Memory, whose persistence authority is the Workspace's Server-managed `subjektiv` Feature database. -The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations. +Legacy Runtime Workers query and mutate the old document through the established typed `/api/w/{workspace_id}/memory...` operations and `MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and legacy `MemoryStaging*` tools. While those surfaces remain available, their existing inputs keep their existing Workspace-document meaning. They must not be silently redirected to subjektiv, assigned an inferred subject, or reused with a new interpretation. + +Worker-side `memory` code owns shared schemas and transport types, not persistence. Legacy `AppendAudit` is acknowledgement-only and has no dedicated durable Memory-audit rows. General Session logs and diagnostics remain independently durable under their own retention policies. Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command. -Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts. +Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only Session records for exact current facts. + +## subjektiv cutover and reset + +Enabling `feature.subjektiv` does not migrate or erase legacy rows. The only supported destructive boundary is the explicit, authorized legacy reset API described by the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). It atomically deletes the old document, staging candidates, and staging resolutions while retaining Workspace Memory settings, Sessions and diagnostics, Tickets, Knowledge, Skills, the subjektiv Feature database, and unrelated Workspace data. The reset is operator-triggered and idempotent; it is never a startup migration or Profile side effect. ## Upgrade guidance -Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification. +Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the intended Workspace-backed product surface. Do not assume the legacy control-plane reset imports repository-local or legacy Workspace data into subjektiv. Stale files may be archived or deleted only after verification. ## Preserved filesystem boundaries -This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority. +Repository-local Memory removal and the control-plane legacy reset do not affect repository checkouts, Git metadata, Runtime-owned Workdirs, Session logs or artifacts, Server data directories, XDG client/server configuration, or the subjektiv Feature database. They remove only their explicitly named legacy authority. diff --git a/docs/design/overview.md b/docs/design/overview.md index cb54d15f9..5fdb66d45 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d - `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend. - `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract. - `tools` implements built-in tools with bounded output and policy-aware execution. -- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane. +- `memory` owns shared legacy Memory transport plus extraction/evidence schemas, not persistence. Legacy single-document Memory lives in the Workspace Server control-plane database; subject-scoped Memory lives in the Workspace's Server-managed `subjektiv` Feature database. - `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files. - `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state. diff --git a/docs/design/server-feature-storage.md b/docs/design/server-feature-storage.md index 8572431ba..8a3d3f36b 100644 --- a/docs/design/server-feature-storage.md +++ b/docs/design/server-feature-storage.md @@ -1,6 +1,6 @@ # Server-managed Feature storage -`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. +`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. Subject-scoped Memory is persisted in the Workspace's `subjektiv` Feature database through this boundary; legacy single-document Workspace Memory remains in the control-plane database and is not owned, imported, or reset by `FeatureStorage`. ## Ownership boundary @@ -126,7 +126,7 @@ Not registering/opening a disabled Feature creates nothing. Disabling a previous `WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed. -Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. +Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. In particular, the [subjektiv product cutover](../development/subjektiv-product-cutover.md) stops legacy and subjektiv activity before pairing a control-plane backup with the subjektiv Feature backup; the explicit legacy reset never deletes or recreates the Feature database. `restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API. diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index bd0c77815..f9b1c2a00 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -1,8 +1,13 @@ # subjektiv subject Memory store -T-667 introduces a trusted Server-side `subjektiv` repository in -`yoi_workspace_server::subjektiv`. It is a domain store, not a Worker tool or a -cutover of the existing Workspace Memory authority. +The trusted Server-side `subjektiv` repository in +`yoi_workspace_server::subjektiv` is the persistence authority for subject-scoped +Memory. It is a domain store, not a Worker tool. Legacy single-document Workspace +Memory remains a separate, deprecated control-plane authority until an operator +performs the explicit reset defined by the +[subjektiv product cutover runbook](../development/subjektiv-product-cutover.md); +legacy rows are never imported, dual-written, or silently reinterpreted as subject +Memory. ## Ownership and scope @@ -85,8 +90,9 @@ immutable. A resolution stores both the resolution JSON and the exact staged JSO bytes that were resolved. Aggregate seal rows prevent evidence, derivations, resolution targets, or snapshot references from being appended after their parent JSON is finalized. Foreign keys plus retention triggers prevent dangling -candidate, derivation, resolution-target, and snapshot references. There is no -record deletion API in this baseline. +candidate, derivation, resolution-target, and snapshot references. Ordinary +subject operations expose no record deletion API; the legacy reset contract does +not touch this Feature database. Search columns and JSON projections are written together by the typed repository. Raw SQL remains private trusted implementation detail. Existing Workspace Memory @@ -280,3 +286,34 @@ proposal unresolved/conflicted. It must not parse target information from the claim, retarget to the latest revision, or revive a retracted Memory. Valid transitions at staging are revise while active/resolved, active→resolved, active/resolved→retracted, and resolved→active. Retraction remains terminal. + +## Candidate consolidation and corrections (T-670) + +The Backend starts a restricted `builtin:subjektiv-memory-consolidation` Worker +for exactly one authenticated subject. The Host binds the subject; model-visible +inputs cannot select a Workspace, subject, Runtime, Worker, or Session. Its tools +list/read pending immutable candidates, query/read confirmed revisions, and make +one candidate decision. Candidate text is evidence rather than authority. + +Every decision carries a stable request ID. An exact retry returns the committed +receipt, while reuse with different input is a conflict. Applying a candidate +atomically writes one new confirmed revision, its exact candidate and derivation +edges, the immutable resolution and affected revision references, and advances +the subject store revision. A non-applied decision explicitly records +`discarded`, `invalid`, `duplicate`, or `already_covered`; an empty model response, +transport failure, or aborted consolidation records no disposition and leaves the +candidate pending. A successful applied response is the only evidence that +confirmed Memory changed. + +A typed revision proposal must be applied to its exact `memory_id` and +`expected_revision`, preserving its intent and change reason. The transaction +rechecks the current revision and state transition. It never rebases or retargets +a stale proposal, revives retracted Memory, or extracts a target from claim prose. +Correction/refinement of the same experience appends under the same Memory ID; +a corrected experience after terminal retraction receives a new Memory ID and may +cite the retracted fixed revision as a derivation. Surface generation is a +separate post-consolidation lifecycle: its failure cannot roll back a committed +candidate decision. + +For the product activation, legacy reset, and operator recovery boundary, follow +the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index c76050af8..76f957018 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -22,7 +22,34 @@ Every editor point carries one or more exact `{memory_id, revision}` references. Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. -Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. The resident Backend contract transports typed `ready`, `un-generated`, `failed`, and `stale` availability separately from optional Markdown, so a valid ready-empty snapshot cannot be conflated with omission or request failure. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Runtime-owned new Workers install Features before durably materializing the initial session head, ensuring the current contribution is present in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface, an explicit ready-empty state, or a no-current-surface tombstone; subsequent turns in the same restored process do not append it again. +Resident injection uses five distinct product states: + +- `ready`: inject the non-empty snapshot only when its `built_from_store_revision` + equals the subject's exact current store revision; +- `ready-empty`: a successful current generation selected no Markdown; inject no + prose, but preserve the explicit successful-empty state; +- `ungenerated`: no successful current generation exists; inject nothing; +- `stale`: confirmed Memory changed after publication (or the snapshot predates + this generation policy); inject nothing until regeneration succeeds; +- `failed`: the generation attempt failed; inject nothing and preserve the typed + failure for diagnostics/retry rather than representing it as empty Memory. + +The resident Backend transports availability separately from optional Markdown. +In storage, `ready-empty` is a current `ready` snapshot with empty Markdown; it is +never conflated with `ungenerated`, `stale`, `failed`, request failure, or +omission. Publication is append-only: generation inputs/runs, failures, snapshots, +and exact references remain immutable history, while current state points at the +applicable result. + +Runtime-owned new Workers install Features before durably materializing the +initial Session head, so `ready` content or the explicit `ready-empty` result is +represented at initial prompt construction. Restored Workers preserve their +persisted prompt and all prior history verbatim. They append one durable +restore-boundary system item that supersedes earlier resident summaries with the +latest `ready` surface, an explicit `ready-empty` state, or a +no-current-surface tombstone for `ungenerated`, `stale`, or `failed`; subsequent +turns in that restored process do not append the boundary again. No regeneration, +correction, failure, or cutover rewrites a committed Session entry. ## Semantic fixture and verification @@ -45,4 +72,4 @@ A valid review fixture must: - cite only exact refs supplied with those materials; - distinguish mechanical citation validation from a human/model semantic review of the wording. -The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/un-generated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. +The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/ungenerated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. diff --git a/docs/development/subjektiv-product-cutover.md b/docs/development/subjektiv-product-cutover.md new file mode 100644 index 000000000..d2640f6cd --- /dev/null +++ b/docs/development/subjektiv-product-cutover.md @@ -0,0 +1,98 @@ +# subjektiv product cutover runbook + +This runbook defines the product and operator boundary for moving a Workspace from legacy single-document Memory to subject-scoped subjektiv Memory. The cutover changes which product path is active; it does not reinterpret legacy tool or API inputs, migrate legacy records into subjektiv, or make historical Worker attribution into current authority. + +T-672 documents and validates the contract only. It does **not** authorize or perform a reset in any real environment. + +## Authorities and persistence + +Two independent Memory systems exist during the transition: + +| Product path | Persistence owner | Durable content | Status | +| --- | --- | --- | --- | +| Legacy Workspace Memory | Workspace Server control-plane database | one Workspace document, staging candidates, and staging resolutions | legacy/deprecated; retained only for compatibility and explicit reset | +| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory revisions, Session attribution, surface generation records, and immutable surface snapshots | current product path | + +The shared `memory` crate and Worker Features do not own either database. `FeatureStorage` owns the subjektiv SQLite lifecycle (path, connection, migration, backup/restore, shutdown, and Workspace deletion), while the subjektiv repository owns its schema and domain transactions. The legacy control-plane tables are not imported or dual-written into subjektiv. + +Every subject operation is bounded first by the authenticated Workspace and then by an explicitly authorized subject. A subject ID is a selector, not a credential. The Host derives subject scope from authenticated Runtime/Worker source proof and current ownership of the opaque `subjektiv:` Worker key; model-visible requests do not supply or expand that authority. + +The current Worker remains Yoi's keyed-singleton responsibility. The subjektiv database records immutable historical Worker and Session attribution, but it contains no second `current_worker_id` mapping and historical attribution is never used to choose or authorize the current Worker. Stopping or replacing a Worker does not retire its subject, and retiring a subject does not stop a Worker. + +Workspace Memory settings remain Workspace-owned configuration. When `feature.subjektiv` is enabled, the Backend binds the trusted settings snapshot used for extraction language; the snapshot is not subject identity and cannot be authored by a Profile, Browser, or model. + +## Session, Memory, and correction semantics + +- **Session attribution and recall:** only a committed primary-Worker Session with Host-recorded subject attribution enters subject Session discovery. Attribution is immutable and idempotent. Recall reads the retained or committed-archive Session projection under current Workspace, subject, observation, and retention authorization; it does not copy Session bodies into subjektiv, infer attribution for older Sessions, restore a Worker, or turn recalled tool output into `HumanInput`. +- **`SubjektivMemoryRemember`:** stages a new candidate; it does not directly create confirmed Memory. Explicit committed evidence is resolved against the Session capture. A non-preference request without an entry is staged only after its tool call commits, using the committed call as model-origin evidence. An uncommitted operation creates no candidate. +- **`SubjektivMemoryProposeRevision`:** stages a candidate with typed target metadata: exact Memory ID, expected revision, intent, and change reason. It does not mutate the target or silently retarget to a newer revision. +- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed revision, exact provenance and affected revision references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision. +- **Corrections:** refinement or correction of the same experience appends the next revision under the same Memory ID using an expected-revision check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed revision. Old revisions, candidates, resolutions, provenance, and surface snapshots remain immutable history. + +Confirmed Memory is useful subject context, not authorization and not exact implementation authority. Tickets, Objectives, repository state, Git history, and append-only Session records remain the exact sources for implementation state. + +## Resident surface and history + +A subject surface is a bounded rendering of current active confirmed revisions, not another Memory authority. The injection contract is: + +- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current store revision; +- **ready-empty:** explicitly record that a successful current generation produced no Markdown and inject no Memory prose; do not conflate this with missing or failed generation; +- **ungenerated:** inject nothing and report no current surface; +- **stale:** inject nothing until regeneration publishes a snapshot for the current store revision; +- **failed:** inject nothing; retain the failure for diagnosis and retry rather than presenting it as an empty Memory. + +A new Worker receives the current ready or ready-empty result in its initial durable prompt construction. A restored Worker does not rewrite its persisted prompt or prior Session history. It appends one durable restore-boundary system item that supersedes prior resident summaries with the latest ready surface, explicit ready-empty state, or a no-current-surface tombstone. Later turns in the same restored process do not append the boundary again. This preserves append-only history: no stale summary, failed generation, correction, or cutover operation edits previously committed Session entries or immutable subjektiv records. + +## Legacy/deprecated compatibility boundary + +The existing legacy tools (`MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and the legacy `MemoryStaging*` operations) and `/api/w/{workspace_id}/memory...` APIs retain their established Workspace-document meanings while they exist. They are legacy/deprecated surfaces, not aliases for subject Memory. Do not silently route an old request to subjektiv, infer a subject from old input, or change an old input field's meaning. + +New product work uses the explicit subject/session/confirmed-Memory/revision/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema. + +## Explicit legacy reset contract + +The legacy reset is a destructive, operator-triggered API operation scoped to one authorized Workspace. Startup, Profile resolution, enabling `feature.subjektiv`, Worker restore, consolidation, and ordinary reads must never trigger it implicitly. + +In one control-plane database transaction, the reset deletes all of the following legacy rows for the Workspace: + +1. the legacy Memory document; +2. all legacy Memory staging candidates; +3. all legacy Memory staging resolutions. + +The operation is all-or-nothing. A failure before commit leaves all three legacy sets unchanged. A retry after success is idempotent: absence of those rows is success and must not delete anything outside the legacy set. + +Legacy `AppendAudit` was acknowledgement-only and created no dedicated durable Memory-audit rows, so there is no fourth legacy audit table to erase. General Session logs and diagnostics remain retained under their own policies. + +The reset must retain: + +- Workspace Memory settings and their revision; +- Sessions, Session logs, archives, and diagnostics; +- Tickets, Objectives, and their audit/history; +- Knowledge and Skills; +- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed revisions, attribution, generation runs, failures, and surface snapshots; +- all other new-product and unrelated Workspace data. + +The reset is not a subject deletion API, a Workspace deletion API, a settings reset, a retention override, or a data migration. + +## Operator procedure + +Perform the cutover in this order: + +1. **Stop legacy activity.** Disable legacy `feature.memory` Profiles and automatic extraction/consolidation, stop or drain old Workers, and block Browser/API clients from writing `/memory` routes. Wait for in-flight legacy writes to finish. Do not run legacy and new automatic extraction for the same Worker. +2. **Back up while writers are stopped.** Capture the control-plane database containing legacy Memory and a `FeatureStorage` backup containing subjektiv. Record the Workspace and backup identities and verify the backup artifacts. Feature backup is internally consistent, but there is no cross-database snapshot instant; stopping both writer sets is what makes the pair a usable cutover checkpoint. +3. **Trigger reset explicitly.** An authenticated operator invokes the legacy reset API for the intended Workspace. Do not automate this as a startup migration or infer consent from `feature.subjektiv` enablement. +4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed revisions, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:` singleton and has only the intended new tools. +5. **Resume only the new path.** Start subject Workers with `feature.subjektiv`, keep legacy write surfaces disabled, and monitor typed surface/Session diagnostics. Do not treat an ungenerated, stale, or failed surface as ready-empty. + +### Failure and recovery + +- If reset returns a definite failure, the transaction rollback means legacy document, staging, and resolutions remain together. Correct the cause and rerun the same explicit operation. +- If communication is lost and the result is ambiguous, keep legacy writers stopped, inspect all three legacy sets, and rerun. Idempotency makes a committed first attempt safe to repeat and atomicity prevents a valid partial state. +- If verification of retained data or the new path fails, keep both write paths stopped. Restore the coordinated pre-reset backups according to the normal control-plane and `FeatureStorage` restore procedures, verify them, and only then resume. Never "recover" by deleting or recreating the subjektiv database. +- Preserve Session logs and diagnostics throughout; they are recovery evidence, not reset targets. + +## Validation boundary + +The validation supporting this cutover proves substrate-level store shutdown/reopen behavior and restoration of a Worker object with its persisted history. It does **not** claim a restart of the Server or Runtime process, behavior during communication loss, real-model consolidation or surface quality, or a full-process end-to-end cutover. Those require separate operational validation before a production reset. + +No real environment reset is performed or authorized by T-672. diff --git a/docs/development/ui-ux/product-ux.md b/docs/development/ui-ux/product-ux.md index 3420a646b..5342eb19c 100644 --- a/docs/development/ui-ux/product-ux.md +++ b/docs/development/ui-ux/product-ux.md @@ -57,8 +57,11 @@ Workspaceを選ぶ前のscopeを扱う。 2. Objectives 3. Merge Requests 4. Memory - - Document - - Staging + - Subjects + - Surface + - Committed Memory + - Revisions + - Sources 5. Workers ### Settings @@ -122,6 +125,16 @@ Workspace pageは、次のいずれかを基本形とする。 - narrow viewportでは一列にする。 - form内でfilled actionにするのはsave actionだけとする。 +### Memory + +- Memoryの入口はWorkspace全体のlegacy `Document` / `Staging`ではなく、明示的に選択したsubjectとする。 +- subject detailではcurrent Workerをkeyed-singleton projectionとして表示し、historical Worker/Session attributionをcurrent authorityとして扱わない。 +- `Surface`はresident contextのcurrent stateを表示する。`ready`、`ready-empty`、`ungenerated`、`stale`、`failed`を区別し、空表示やrequest failureへまとめない。 +- `Committed Memory`はcurrent confirmed revisionの一覧とdetailを持つ。revision historyはappend-only viewとして分離し、過去revisionをcurrentへ暗黙fallbackしない。 +- `Sources`は候補、resolution、exact Memory revision refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。 +- legacy `/memory` の`Document` / `Staging` viewはdeprecated compatibility surfaceとして明示し、新しいsubject viewへ同じinputを別の意味で転送しない。 +- Markdown本文とdetail presentationはT-661の共有rendererを再利用する。Memory route独自のrenderer、application shell、Header、Sidebarを再実装せず、既存shellのmain contentとnested navigationを保つ。 + ### Worker Console - transcriptをprimary surfaceとする。 diff --git a/docs/manifest.toml b/docs/manifest.toml index 884866ef7..bc5ac76d3 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -235,32 +235,40 @@ permission = "write" # # ref = "anthropic/claude-haiku-4-5" -# ===== [feature.memory] ====================================================== -# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では -# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを -# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが -# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。 -# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる -# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。 +# ===== [feature.memory] (legacy/deprecated) ================================ +# Legacy single-document Workspace Memory compatibility設定。新規product pathは +# `[feature.subjektiv]` を使う。旧`MemoryQuery` / `MemoryReadDocument` / +# `MemoryUpdateDocument` / `MemoryStaging*` toolsと`/memory` APIのinput意味は +# 変更せず、subject Memoryへ暗黙転送しない。 # -# [feature.memory.profile] +# resolved Worker ManifestではProfile由来の設定を`profile`に、Backend由来の +# Workspace設定snapshotを`workspace_settings`に分離して保存する。source TOMLの +# `[feature.memory]` fieldsはresolve後に`profile`へ入り、`workspace_settings`は +# Backendだけがbindする信頼済み入力で、Profile・Browser・ +# model入力から指定できない。source `enabled = false`(resolvedでは +# `profile.enabled = false`)の場合、legacy Memory tools、 +# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。 +# `[feature.subjektiv]`の有効化やWorker restoreはlegacy dataをreset/migrateしない。 +# legacy resetは認証済みoperatorが明示APIで別途実行する。 +# +# [feature.memory] # enabled = true # staging_tools = false # -# [feature.memory.profile.resident] +# [feature.memory.resident] # inject_summary = true # -# [feature.memory.profile.extraction] +# [feature.memory.extraction] # enabled = true # reasoning = "medium" # threshold = 30000 # worker_max_turns = 8 # # # modelは任意。省略時はmain modelをcloneする。 -# # [feature.memory.profile.extraction.model] +# # [feature.memory.extraction.model] # # ref = "codex-oauth/gpt-5.6-luna" # -# [feature.memory.profile.consolidation] +# [feature.memory.consolidation] # request_enabled = true # # # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。 @@ -273,6 +281,42 @@ permission = "write" # policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。 +# ===== [feature.subjektiv] ================================================ +# Current subject-scoped Memory product。ProfileはFeature activationと自動extract +# policyだけを所有する。subject idはManifest fieldではない。Hostが認証済み +# Runtime/Worker sourceとcurrent `subjektiv:` keyed-singleton ownershipから +# operationごとにbindし、明示Workspace/subject authorizationを再評価する。 +# +# `enabled = true`はsubject tools、Session attribution/recall、confirmed Memory、 +# candidate consolidation、resident surface lifecycleを有効にする。legacy +# `[feature.memory]` dataのimport/reset/dual-writeは行わない。同じWorkerでlegacyと +# subjektivの自動extract destinationを同時に有効化する構成は拒否される。 +# +# [feature.subjektiv] +# enabled = true +# +# [feature.subjektiv.extraction] +# enabled = true +# reasoning = "medium" +# threshold = 50000 +# worker_max_turns = 8 +# +# # modelは任意。省略時はmain modelをcloneする。 +# # [feature.subjektiv.extraction.model] +# # ref = "codex-oauth/gpt-5.6-luna" +# +# # Backendがresolved Manifestへbindする。Profile・Browser・model入力では指定しない。 +# # language/settings revisionのtrusted snapshotであり、subject identityではない。 +# # [feature.subjektiv.workspace_settings] +# # workspace_id = "workspace-id" +# # settings_revision = 1 +# # language = "日本語" +# +# Surface selection/generation/injection、candidate consolidation eligibility、recall +# limitsはHost/Backend policyが所有し、通常Worker Manifestには含めない。current Worker +# authorityはYoi keyed singletonに残り、historical Session attributionを代用しない。 + + # ===== [skills] ============================================================= # 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。 # セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や diff --git a/openapi/server-api.json b/openapi/server-api.json index a024f5f80..37f741ddf 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2604,6 +2604,53 @@ ], "type": "object" }, + "LegacyMemoryResetRequest": { + "additionalProperties": false, + "description": "Explicit destructive reset request. The confirmation must exactly equal\n`RESET LEGACY MEMORY ` for the Workspace named in the route.", + "properties": { + "confirmation": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "confirmation" + ], + "type": "object" + }, + "LegacyMemoryResetResponse": { + "additionalProperties": false, + "properties": { + "documents_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_records_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_resolutions_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "documents_deleted", + "staging_records_deleted", + "staging_resolutions_deleted" + ], + "type": "object" + }, "LogoutResponse": { "additionalProperties": false, "properties": { @@ -8734,6 +8781,535 @@ ], "type": "object" }, + "SubjektivMemoryDetailQuery": { + "additionalProperties": false, + "properties": { + "byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "evidence_cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryEvidenceCandidate": { + "additionalProperties": false, + "properties": { + "candidate_id": { + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/StagingEvidence" + }, + "type": "array" + }, + "evidence_total": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "evidence_truncated": { + "type": "boolean" + }, + "source_refs": { + "items": { + "$ref": "#/components/schemas/SourceEvidenceRef" + }, + "type": "array" + }, + "source_refs_total": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_refs_truncated": { + "type": "boolean" + } + }, + "required": [ + "candidate_id", + "evidence", + "evidence_total", + "evidence_truncated", + "source_refs", + "source_refs_total", + "source_refs_truncated" + ], + "type": "object" + }, + "SubjektivMemoryListQuery": { + "additionalProperties": false, + "description": "Browser GET query for committed subject Memory. Enum lists use a bounded,\ncomma-separated scalar representation so URL-form encoding stays portable.", + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "kinds": { + "items": { + "$ref": "#/components/schemas/CandidateKind" + }, + "type": [ + "array", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "states": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryListRevisionsResponse": { + "additionalProperties": false, + "properties": { + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionItem" + }, + "type": "array" + }, + "memory_id": { + "type": "string" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "memory_id", + "current_revision", + "items", + "has_more" + ], + "type": "object" + }, + "SubjektivMemoryQueryItem": { + "additionalProperties": false, + "properties": { + "claim": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "revision", + "kind", + "state", + "claim", + "excerpt", + "updated_at" + ], + "type": "object" + }, + "SubjektivMemoryQueryResponse": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryQueryItem" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "has_more" + ], + "type": "object" + }, + "SubjektivMemoryReadResponse": { + "additionalProperties": false, + "properties": { + "body_byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "body_md": { + "description": "A line-bounded slice, never an implicit full-document claim.", + "type": "string" + }, + "body_next_byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "body_next_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "body_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "body_truncated": { + "type": "boolean" + }, + "change_reason": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "derived_from": { + "description": "Derivation references on this evidence page.", + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + }, + "type": "array" + }, + "evidence_has_more": { + "type": "boolean" + }, + "evidence_next_cursor": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "memory_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "source_candidate_ids": { + "description": "Candidate ids on this evidence page. A compatible historical candidate\nmay repeat across pages while its nested anchors advance through the same\nevidence cursor. Corresponding bounded anchors retain the same order in\n`source_candidates`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "source_candidates": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryEvidenceCandidate" + }, + "type": "array" + }, + "staleness": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + }, + "why_useful": { + "type": "string" + } + }, + "required": [ + "memory_id", + "revision", + "current_revision", + "kind", + "state", + "claim", + "body_md", + "why_useful", + "change_reason", + "created_at", + "updated_at", + "body_offset", + "body_byte_offset", + "body_truncated", + "source_candidate_ids", + "source_candidates", + "derived_from", + "evidence_has_more" + ], + "type": "object" + }, + "SubjektivMemoryRevisionItem": { + "additionalProperties": false, + "properties": { + "change_reason": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "revision", + "kind", + "state", + "claim", + "change_reason", + "updated_at" + ], + "type": "object" + }, + "SubjektivMemoryRevisionRef": { + "additionalProperties": false, + "properties": { + "memory_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "memory_id", + "revision" + ], + "type": "object" + }, + "SubjektivMemoryRevisionsQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryState": { + "description": "Current lifecycle state of one confirmed subject Memory revision.", + "enum": [ + "active", + "resolved", + "retracted" + ], + "type": "string" + }, + "SubjektivResidentSurfaceAvailability": { + "enum": [ + "ungenerated", + "stale", + "failed", + "ready" + ], + "type": "string" + }, + "SubjektivResidentSurfaceResponse": { + "additionalProperties": false, + "properties": { + "availability": { + "$ref": "#/components/schemas/SubjektivResidentSurfaceAvailability" + }, + "snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjektivResidentSurfaceSnapshot" + }, + { + "type": "null" + } + ] + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "subject_id", + "availability" + ], + "type": "object" + }, + "SubjektivResidentSurfaceSnapshot": { + "additionalProperties": false, + "properties": { + "body_md": { + "type": "string" + }, + "built_from_store_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "memory_refs": { + "default": [], + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + }, + "type": "array" + }, + "snapshot_id": { + "type": "string" + } + }, + "required": [ + "snapshot_id", + "body_md", + "built_from_store_revision", + "created_at" + ], + "type": "object" + }, "SubjektivSubjectCreateRequest": { "additionalProperties": false, "properties": { @@ -8746,6 +9322,47 @@ ], "type": "object" }, + "SubjektivSubjectListQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivSubjectListResponse": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "limit", + "items", + "has_more" + ], + "type": "object" + }, "SubjektivSubjectResponse": { "additionalProperties": false, "properties": { @@ -14121,7 +14738,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0" + "x-yoi-source-digest": "sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3" }, "openapi": "3.1.0", "paths": { @@ -19140,6 +19757,103 @@ ] } }, + "/api/w/{workspace_id}/memory/reset": { + "post": { + "operationId": "legacy_memory_reset", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LegacyMemoryResetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LegacyMemoryResetResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/memory/staging": { "get": { "operationId": "memory_staging_list", @@ -28063,6 +28777,101 @@ } }, "/api/w/{workspace_id}/subjektiv/subjects": { + "get": { + "operationId": "subjektiv_subject_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectListResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, "post": { "operationId": "subjektiv_subject_create", "parameters": [ @@ -28266,6 +29075,357 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { + "get": { + "operationId": "subjektiv_memory_list", + "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" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryQueryResponse" + } + } + }, + "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}/memories/{memory_id}": { + "get": { + "operationId": "subjektiv_memory_detail", + "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" + }, + { + "explode": false, + "in": "path", + "name": "memory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryDetailQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryReadResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions": { + "get": { + "operationId": "subjektiv_memory_revisions", + "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" + }, + { + "explode": false, + "in": "path", + "name": "memory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryListRevisionsResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": { "post": { "operationId": "subjektiv_subject_retire", @@ -28373,6 +29533,103 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface": { + "get": { + "operationId": "subjektiv_resident_surface", + "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/SubjektivResidentSurfaceResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker": { "post": { "operationId": "subjektiv_subject_worker_start", diff --git a/resources/profiles/backend-job.dcdl b/resources/profiles/backend-job.dcdl index 15c64d83c..8f2afe8d7 100644 --- a/resources/profiles/backend-job.dcdl +++ b/resources/profiles/backend-job.dcdl @@ -12,6 +12,7 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; memory = { enabled = false; }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = false; }; image = { enabled = false; }; sub_worker = { enabled = false; }; diff --git a/resources/profiles/base.dcdl b/resources/profiles/base.dcdl index 04e42783f..fd8f71a7e 100644 --- a/resources/profiles/base.dcdl +++ b/resources/profiles/base.dcdl @@ -24,16 +24,15 @@ compaction = { feature = { task = { enabled = true; }; - memory = { + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = true; - resident = { inject_summary = true; }; extraction = { enabled = true; model = { ref = "codex-oauth/gpt-5.6-luna"; }; reasoning = "medium"; threshold = 50000; }; - consolidation = { request_enabled = true; }; }; web = { enabled = true; }; image = { enabled = true; }; diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 5067ddca8..174e8d359 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -10,7 +10,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = true; }; flow = { enabled = true; }; diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index 43366d48d..bbce5930e 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = true; }; worker = { enabled = true; direct_spawn = false; }; diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index e12ca0a97..4ddfb0a5e 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -7,6 +7,7 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = true; }; image = { enabled = true; }; sub_worker = { enabled = true; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index 1778c9307..649b4e166 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/memory-consolidation.dcdl b/resources/profiles/memory-consolidation.dcdl index 86eb50670..4333bc931 100644 --- a/resources/profiles/memory-consolidation.dcdl +++ b/resources/profiles/memory-consolidation.dcdl @@ -7,7 +7,19 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; - memory = { enabled = true; staging_tools = true; }; + memory = { + enabled = true; + staging_tools = true; + resident = { inject_summary = true; }; + extraction = { + enabled = true; + model = { ref = "codex-oauth/gpt-5.6-luna"; }; + reasoning = "medium"; + threshold = 50000; + }; + consolidation = { request_enabled = true; }; + }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = false; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index cb654b633..dee53280a 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = true; direct_spawn = false; }; diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index 29ee93768..6b9769bca 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -10,7 +10,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/subjektiv-memory-consolidation.dcdl b/resources/profiles/subjektiv-memory-consolidation.dcdl index f7fbba83e..b3c9ec16d 100644 --- a/resources/profiles/subjektiv-memory-consolidation.dcdl +++ b/resources/profiles/subjektiv-memory-consolidation.dcdl @@ -8,14 +8,8 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; - memory = { - enabled = true; - staging_tools = true; - resident = { inject_summary = false; }; - extraction = { enabled = false; }; - consolidation = { request_enabled = false; }; - }; - subjektiv = { enabled = false; extraction = { enabled = false; }; }; + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = true; extraction = { enabled = false; }; }; web = { enabled = false; }; image = { enabled = false; }; sub_worker = { enabled = false; }; diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 89fa3d098..d97aaf8cd 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -11,23 +11,263 @@ if ( } const workspaceId = "memory-review"; +const representativeSubjectId = + "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; +const representativeMemoryId = + "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001"; +const derivedMemoryId = "memory-derived-source-0002"; +const emptySubjectId = "empty-subject"; +const staleSubjectId = "stale-subject"; +const failedSubjectId = "failed-subject"; +const ungeneratedSubjectId = "ungenerated-subject"; +const errorSubjectId = "error-subject"; + const paragraphs = Array.from( { length: sectionCount }, (_, index) => `## Section ${ index + 1 - }\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${ + }\n\nThis is representative resident Memory context with **important context**, an identifier \`memory-item-${ index + 1 }\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${ index + 1 }.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`, ).join("\n\n"); -const bodyMd = - `# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${ +const surfaceBodyMd = + `# Resident subject context\n\nThis generated surface is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${ "wide-value-".repeat(16) - } | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${ + } | stable | keep scrolling local |\n\n##### Deep surface heading\n\n###### Deepest surface heading\n\n\`\`\`rust\nfn example() { println!("${ "wide-code-".repeat(18) - }"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`; + }"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n\n\n${paragraphs}\n\n## Surface content complete`; + +const detailBodyMd = `# Committed Memory detail + +This immutable revision preserves its candidate evidence, source refs, and derivation refs. + +| Local table overflow | Value | +| --- | --- | +| ${"detail-wide-value-".repeat(14)} | stable | + +\`\`\`text +${"detail-wide-code-".repeat(18)} +\`\`\` + +[Safe evidence link](https://example.com/evidence) [Unsafe evidence link](javascript:alert(2)) + +`; + +const subjects = [ + subject(representativeSubjectId, "Release coordination", "active", 42), + subject(emptySubjectId, "Empty ready subject", "active", 0), + subject(staleSubjectId, "Stale surface subject", "active", 19), + subject(failedSubjectId, "Failed surface subject", "active", 8), + subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0), + subject(errorSubjectId, "Unavailable subject", "retired", 3), +]; + +function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { + return { + id, + role, + state, + store_revision: storeRevision, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + }; +} + +function memoriesFor(subjectId: string) { + if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; + if (subjectId === staleSubjectId) { + return [ + memorySummary("stale-memory-1", 2, "lesson", "resolved", "A resolved stale-surface lesson"), + ]; + } + if (subjectId === failedSubjectId) { + return [ + memorySummary( + "failed-memory-1", + 1, + "constraint", + "retracted", + "A retracted failed-surface constraint", + ), + ]; + } + return [ + memorySummary( + representativeMemoryId, + 3, + "decision", + "active", + "Keep subject Memory provenance typed and visible", + ), + memorySummary( + "memory-resolved-0002", + 2, + "lesson", + "resolved", + "The bounded reader must preserve continuations", + ), + memorySummary( + "memory-retracted-0003", + 1, + "working_assumption", + "retracted", + "T-672 UNIQUE END MARKER", + ), + ]; +} + +function memorySummary( + id: string, + revision: number, + kind: string, + state: string, + claim: string, +) { + return { + id, + revision, + kind, + state, + claim, + excerpt: `Read-only ${state} Memory with exact revision and provenance metadata.`, + updated_at: "2026-01-02T03:04:05Z", + }; +} + +function surfaceFor(subjectId: string) { + if (subjectId === representativeSubjectId) { + return { + subject_id: subjectId, + availability: "ready", + snapshot: { + snapshot_id: "surface-snapshot-0042", + body_md: surfaceBodyMd, + memory_refs: [ + { memory_id: representativeMemoryId, revision: 3 }, + { memory_id: derivedMemoryId, revision: 4 }, + ], + built_from_store_revision: 42, + created_at: "2026-01-02T03:04:05Z", + }, + }; + } + if (subjectId === emptySubjectId) { + return { + subject_id: subjectId, + availability: "ready", + snapshot: { + snapshot_id: "surface-snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-01-02T03:04:05Z", + }, + }; + } + if (subjectId === staleSubjectId) return { subject_id: subjectId, availability: "stale" }; + if (subjectId === failedSubjectId) return { subject_id: subjectId, availability: "failed" }; + return { subject_id: subjectId, availability: "ungenerated" }; +} + +function memoryDetail(memoryId: string, requestedRevision: number | null) { + const revision = requestedRevision && requestedRevision <= 3 ? requestedRevision : 3; + const states = { 1: "retracted", 2: "resolved", 3: "active" } as const; + return { + memory_id: memoryId, + revision, + current_revision: 3, + kind: "decision", + state: states[revision as keyof typeof states], + claim: revision === 3 + ? "Keep subject Memory provenance typed and visible" + : `Historical provenance decision, revision ${revision}`, + body_md: detailBodyMd, + why_useful: + "Future readers can audit why a committed Memory exists without exposing unbounded session content.", + staleness: null, + change_reason: revision === 3 + ? "Clarified the durable read contract." + : "Immutable historical revision.", + created_at: "2026-01-01T00:00:00Z", + updated_at: `2026-01-0${revision}T03:04:05Z`, + body_offset: 0, + body_byte_offset: 0, + body_truncated: false, + source_candidate_ids: ["candidate-release-decision-0001"], + source_candidates: [{ + candidate_id: "candidate-release-decision-0001", + evidence: [{ + id: "evidence-message-0001", + kind: "message", + entry_range: [12, 14], + origin: { + kind: "flow_instruction", + workspace_id: workspaceId, + runtime_id: "runtime-fixture", + worker_id: "worker-fixture", + flow_selector: "builtin:coder-review", + flow_definition_id: "flow-definition-fixture", + flow_definition_revision: 7, + }, + excerpt: "Use normal explicit subject routes and preserve provenance.", + summary: "The product contract was explicitly requested.", + }], + evidence_total: 1, + evidence_truncated: false, + source_refs: [{ + session_id: "session-fixture-0001", + segment_id: "segment-fixture-0001", + entry_range: [12, 14], + evidence_id: "evidence-message-0001", + origin: { kind: "human_input", account_id: "account-fixture" }, + evidence_kind: "message", + label: "T-672 product direction", + summary: "Bounded source reference retained with the committed revision.", + }], + source_refs_total: 1, + source_refs_truncated: false, + }], + derived_from: [{ memory_id: derivedMemoryId, revision: 4 }], + evidence_has_more: false, + }; +} + +function revisions(memoryId: string) { + return { + memory_id: memoryId, + current_revision: 3, + items: [ + { + revision: 3, + kind: "decision", + state: "active", + claim: "Keep subject Memory provenance typed and visible", + change_reason: "Clarified the durable read contract.", + updated_at: "2026-01-03T03:04:05Z", + }, + { + revision: 2, + kind: "decision", + state: "resolved", + claim: "Historical provenance decision, revision 2", + change_reason: "Resolved after endpoint integration.", + updated_at: "2026-01-02T03:04:05Z", + }, + { + revision: 1, + kind: "decision", + state: "retracted", + claim: "Historical provenance decision, revision 1", + change_reason: "Retracted the legacy shape.", + updated_at: "2026-01-01T03:04:05Z", + }, + ], + has_more: false, + }; +} const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { @@ -110,15 +350,34 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } - if (url.pathname === `/api/w/${workspaceId}/memory`) { - return json({ - body_md: bodyMd, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: new TextEncoder().encode(bodyMd).byteLength, - record_source: "sqlite_workspace_authority.memory_document", - }); + + const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; + if (url.pathname === subjectsPath) { + return json({ limit: 100, items: subjects, has_more: false }); } + if (url.pathname.startsWith(`${subjectsPath}/`)) { + const suffix = url.pathname.slice(subjectsPath.length + 1); + const parts = suffix.split("/").map(decodeURIComponent); + const subjectId = parts[0]; + if (subjectId === errorSubjectId) return json({}, 503); + const foundSubject = subjects.find((item) => item.id === subjectId); + if (!foundSubject) return json({}, 404); + + if (parts.length === 1) return json(foundSubject); + if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); + if (parts.length === 2 && parts[1] === "memories") { + return json({ items: memoriesFor(subjectId), has_more: false }); + } + if (parts.length >= 3 && parts[1] === "memories") { + const memoryId = parts[2]; + if (parts.length === 4 && parts[3] === "revisions") return json(revisions(memoryId)); + if (parts.length === 3) { + const requested = url.searchParams.get("revision"); + return json(memoryDetail(memoryId, requested === null ? null : Number(requested))); + } + } + } + if ( url.pathname === `/api/w/${workspaceId}/protocol/ws` && request.headers.get("upgrade") === "websocket" diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 54c8d403f..873e7d0a7 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -9,6 +9,10 @@ const fixtureServer = join( "tools/web-ux/browser-tests/memory_document_fixture_server.ts", ); const workspaceId = "memory-review"; +const subjectId = + "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; +const memoryId = + "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001"; async function freePort(): Promise { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); @@ -41,8 +45,8 @@ async function markerIsInsideMain(page: Page): Promise { return await page.evaluate(() => { const document = (globalThis as any).document; const main = document.querySelector("main"); - const marker = [...document.querySelectorAll("h2")].find((heading) => - heading.textContent === "T-661 UNIQUE END MARKER" + const marker = [...document.querySelectorAll("h3")].find((heading) => + heading.textContent === "T-672 UNIQUE END MARKER" ); if (!main || !marker) return false; const mainRect = main.getBoundingClientRect(); @@ -51,31 +55,86 @@ async function markerIsInsideMain(page: Page): Promise { }); } -async function openMemoryPage( +async function assertNoPageWideOverflow(page: Page): Promise { + assertEquals( + await page.evaluate(() => { + const document = (globalThis as any).document; + return document.documentElement.scrollWidth === document.documentElement.clientWidth; + }), + true, + ); +} + +async function assertMainOwnsVerticalScroll(page: Page): Promise { + const nested = await page.locator("main").evaluate((main) => + [...main.querySelectorAll("*")] + .filter((element) => { + const style = (globalThis as any).getComputedStyle(element); + return ["auto", "scroll"].includes(style.overflowY) && + element.scrollHeight > element.clientHeight + 1; + }) + .map((element) => `${element.tagName.toLowerCase()}.${element.className}`) + ); + assertEquals(nested, []); +} + +async function openSubjectPage( page: Page, baseUrl: string, mobile = false, ): Promise { - await page.goto(`${baseUrl}/w/${workspaceId}/memory`); - const heading = page.locator("h1", { hasText: "Workspace Memory" }); + await page.goto(`${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}`); + const heading = page.locator("h1", { hasText: "Release coordination" }); await heading.waitFor({ state: "attached" }); - if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click(); + if (mobile) { + const showSidebar = page.getByRole("button", { name: "Show sidebar" }); + await showSidebar.waitFor(); + assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + false, + ); + + await showSidebar.click(); + const hideSidebar = page.getByRole("button", { name: "Hide sidebar" }); + await hideSidebar.waitFor(); + assertEquals(await hideSidebar.getAttribute("aria-expanded"), "true"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + true, + ); + + await hideSidebar.click(); + await showSidebar.waitFor(); + assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + false, + ); + } await heading.waitFor(); } async function swipeMainToEnd(page: Page): Promise { const session = await page.context().newCDPSession(page); + const viewport = page.viewportSize(); + if (!viewport) throw new Error("touch viewport is unavailable"); + const x = Math.max(20, Math.min(300, viewport.width - 20)); + const startY = Math.max(120, viewport.height - 100); + const moveYs = [0.8, 0.62, 0.44, 0.26, 0.12].map((ratio) => + Math.max(20, Math.round(viewport.height * ratio)) + ); for (let attempt = 0; attempt < 100; attempt += 1) { const state = await mainScrollState(page); if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return; await session.send("Input.dispatchTouchEvent", { type: "touchStart", - touchPoints: [{ x: 300, y: 700, id: 1 }], + touchPoints: [{ x, y: startY, id: 1 }], }); - for (const y of [580, 460, 340, 220, 100]) { + for (const y of moveYs) { await session.send("Input.dispatchTouchEvent", { type: "touchMove", - touchPoints: [{ x: 300, y, id: 1 }], + touchPoints: [{ x, y, id: 1 }], }); } await session.send("Input.dispatchTouchEvent", { @@ -84,10 +143,10 @@ async function swipeMainToEnd(page: Page): Promise { }); await page.waitForTimeout(20); } - throw new Error("touch input did not reach the end of the Memory document"); + throw new Error("touch input did not reach the end of the resident Memory surface"); } -Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => { +Deno.test("production subject Memory shell renders exact states, safe Markdown, provenance, and one scroll owner", async () => { const build = await new Deno.Command(Deno.execPath(), { args: ["task", "build"], cwd: workspaceRoot, @@ -130,6 +189,13 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end colorScheme: "light" as const, mobile: true, }, + { + label: "compact-mobile", + width: 320, + height: 700, + colorScheme: "dark" as const, + mobile: true, + }, ] ) { const context = await browser.newContext({ @@ -144,21 +210,28 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end if (message.type() === "error") errors.push(message.text()); }); page.on("pageerror", (error) => errors.push(String(error))); - await openMemoryPage(page, baseUrl, scenario.mobile); + await openSubjectPage(page, baseUrl, scenario.mobile); const initial = await mainScrollState(page); assert(initial.scrollHeight > initial.clientHeight); assertEquals(await markerIsInsideMain(page), false); + await assertNoPageWideOverflow(page); + await assertMainOwnsVerticalScroll(page); assertEquals( - await page.evaluate(() => { - const document = (globalThis as any).document; - return document.documentElement.scrollWidth === document.documentElement.clientWidth; - }), - true, + await page.locator('aside a[href$="/memory"]', { hasText: "Subjects" }).count(), + 1, ); + assertEquals(await page.locator("aside a", { hasText: "Staging" }).count(), 0); if (scenario.label === "wheel") { - assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]); + assertEquals( + await page.getByRole("heading", { + name: "Release coordination", + level: 1, + exact: true, + }).count(), + 1, + ); assertEquals( await page.locator("strong").filter({ hasText: "important context" }).count() > 0, true, @@ -167,8 +240,8 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end assertEquals(await page.locator("ul li").count() > 0, true); for ( const heading of [ - { selector: "h5", text: "Deep document heading" }, - { selector: "h6", text: "Deepest document heading" }, + { selector: "h5", text: "Deep surface heading" }, + { selector: "h6", text: "Deepest surface heading" }, ] ) { const element = page.locator(heading.selector, { hasText: heading.text }); @@ -182,8 +255,17 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end } assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); - assertEquals(await page.locator("article img, article script").count(), 0); - assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0); + assertEquals( + await page.getByRole("article", { name: "Resident surface" }).locator("img, script") + .count(), + 0, + ); + for (const state of ["Active", "Resolved", "Retracted"]) { + assertEquals( + await page.locator(".memory-pill", { hasText: state }).count(), + 1, + ); + } for (const label of ["Markdown table", "rust code block"]) { const region = page.getByRole("region", { name: label }); @@ -194,7 +276,7 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end await page.locator("main").hover(); await page.mouse.wheel(0, 100_000); } else if (scenario.label === "keyboard") { - await page.getByText("Document details", { exact: true }).focus(); + await page.getByText("Surface provenance", { exact: true }).focus(); await page.keyboard.press("End"); } else { await swipeMainToEnd(page); @@ -213,33 +295,72 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end await context.close(); } - const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); - const emptyPage = await stateContext.newPage(); - await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - body_md: " \n", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: 2, - record_source: "fixture", - }), - }); - }); - await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`); - await emptyPage.getByText("Memory document is empty.").waitFor(); - assertEquals(await emptyPage.getByRole("alert").count(), 0); + const detailContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const detailPage = await detailContext.newPage(); + await detailPage.goto( + `${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}/${ + encodeURIComponent(memoryId) + }`, + ); + await detailPage.getByRole("heading", { + name: "Keep subject Memory provenance typed and visible", + level: 1, + }).waitFor(); + assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); + assertEquals(await detailPage.getByText("T-672 product direction").count(), 1); + assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); + assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); + assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 1); + assertEquals(await detailPage.locator('a[href^="javascript:"]').count(), 0); + assertEquals( + await detailPage.getByRole("article", { name: "Committed Memory body" }).locator( + "script, img", + ).count(), + 0, + ); + for (const label of ["Markdown table", "text code block"]) { + const region = detailPage.getByRole("region", { name: label }); + assertEquals(await region.getAttribute("tabindex"), "0"); + assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth)); + } + await assertNoPageWideOverflow(detailPage); + await assertMainOwnsVerticalScroll(detailPage); + await detailContext.close(); - const errorPage = await stateContext.newPage(); - await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { - await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }); - }); - await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`); - const alert = errorPage.getByRole("alert"); - await alert.waitFor(); - assert((await alert.textContent())?.includes("Memory document unavailable.")); - assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0); + const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const stateCases = [ + { + id: "empty-subject", + text: "Resident surface is ready and empty.", + role: "status" as const, + }, + { id: "stale-subject", text: "Resident surface is stale.", role: "status" as const }, + { + id: "failed-subject", + text: "Resident surface generation failed.", + role: "alert" as const, + }, + { + id: "ungenerated-subject", + text: "Resident surface has not been generated.", + role: "status" as const, + }, + { id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const }, + ]; + for (const stateCase of stateCases) { + const page = await stateContext.newPage(); + await page.goto(`${baseUrl}/w/${workspaceId}/memory/${stateCase.id}`); + const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text }); + await state.waitFor(); + assert((await state.textContent())?.includes(stateCase.text)); + if (stateCase.id !== "empty-subject") { + assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0); + } + await assertNoPageWideOverflow(page); + await page.close(); + } await stateContext.close(); } finally { await browser.close(); diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json new file mode 100644 index 000000000..c09324b6d --- /dev/null +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "id": "memory-subjects-dark", + "title": "Memory subjects, surfaces, and committed records — dark", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Read-only Memory fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [ + { + "id": "subjects", + "label": "Subject index", + "path": "/w/memory-review/memory", + "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", + "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", + "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, + "capturePoints": [{ "id": "initial", "label": "Subjects" }] + }, + { + "id": "representative", + "label": "Ready subject surface", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + "goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.", + "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + }, + "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + }, + { + "id": "detail", + "label": "Committed Memory detail", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", + "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "capturePoints": [{ "id": "initial", "label": "Memory detail" }] + }, + { + "id": "empty", + "label": "Ready-empty subject", + "path": "/w/memory-review/memory/empty-subject", + "goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.", + "dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]" + }, + "capturePoints": [{ "id": "initial", "label": "Ready empty" }] + }, + { + "id": "stale", + "label": "Stale subject surface", + "path": "/w/memory-review/memory/stale-subject", + "goal": "Recognize that no snapshot is displayable while committed Memories remain readable.", + "dataState": "Stale availability without snapshot and one resolved committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='stale']" + }, + "capturePoints": [{ "id": "initial", "label": "Stale" }] + }, + { + "id": "failed", + "label": "Failed subject surface", + "path": "/w/memory-review/memory/failed-subject", + "goal": "See generation failure without displaying a failed or partial snapshot.", + "dataState": "Failed availability without snapshot and one retracted committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='failed']" + }, + "capturePoints": [{ "id": "initial", "label": "Failed" }] + }, + { + "id": "error", + "label": "Subject endpoint error", + "path": "/w/memory-review/memory/error-subject", + "goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.", + "dataState": "Intentional HTTP 503 from all subject-scoped endpoints.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='error']" + }, + "capturePoints": [{ "id": "initial", "label": "Unavailable" }] + } + ], + "processes": [{ + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15286", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15286/health" + }] +} diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json new file mode 100644 index 000000000..63aebb43e --- /dev/null +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "id": "memory-subjects-light", + "title": "Memory subjects, surfaces, and committed records — light", + "baseUrl": "http://127.0.0.1:15285", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Read-only Memory fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [ + { + "id": "subjects", + "label": "Subject index", + "path": "/w/memory-review/memory", + "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", + "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", + "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, + "capturePoints": [{ "id": "initial", "label": "Subjects" }] + }, + { + "id": "representative", + "label": "Ready subject surface", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + "goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.", + "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + }, + "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + }, + { + "id": "detail", + "label": "Committed Memory detail", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", + "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "capturePoints": [{ "id": "initial", "label": "Memory detail" }] + }, + { + "id": "empty", + "label": "Ready-empty subject", + "path": "/w/memory-review/memory/empty-subject", + "goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.", + "dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]" + }, + "capturePoints": [{ "id": "initial", "label": "Ready empty" }] + }, + { + "id": "stale", + "label": "Stale subject surface", + "path": "/w/memory-review/memory/stale-subject", + "goal": "Recognize that no snapshot is displayable while committed Memories remain readable.", + "dataState": "Stale availability without snapshot and one resolved committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='stale']" + }, + "capturePoints": [{ "id": "initial", "label": "Stale" }] + }, + { + "id": "failed", + "label": "Failed subject surface", + "path": "/w/memory-review/memory/failed-subject", + "goal": "See generation failure without displaying a failed or partial snapshot.", + "dataState": "Failed availability without snapshot and one retracted committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='failed']" + }, + "capturePoints": [{ "id": "initial", "label": "Failed" }] + }, + { + "id": "error", + "label": "Subject endpoint error", + "path": "/w/memory-review/memory/error-subject", + "goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.", + "dataState": "Intentional HTTP 503 from all subject-scoped endpoints.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='error']" + }, + "capturePoints": [{ "id": "initial", "label": "Unavailable" }] + } + ], + "processes": [{ + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15285", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15285/health" + }] +} diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 5f899c88a..a254e53a6 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index c01a71858..574fe4891 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -11,98 +11,56 @@ export const MEMORY_API_LIMITS = { export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type MemoryDocumentResponse = { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; -}; +export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, }; -export type MemoryCandidateKind = - | "preference" - | "working_assumption" - | "constraint" - | "decision" - | "open_question" - | "lesson"; +export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson"; -export type MemoryEvidenceOriginKind = - | "human_input" - | "worker_input" - | "flow_instruction" - | "backend_instruction" - | "model_output" - | "tool_output" - | "derived_summary" - | "legacy_unknown"; +export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown"; -export type MemoryEvidenceOrigin = { - kind: MemoryEvidenceOriginKind; - account_id?: string | null; - workspace_id?: string | null; - runtime_id?: string | null; - worker_id?: string | null; - flow_selector?: string | null; - flow_definition_id?: string | null; - flow_definition_revision?: number | null; -}; +export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, }; -export type MemorySourceRef = { segment_id: string; range: [number, number] }; +export type MemorySourceRef = { segment_id: string, range: [number, number], }; -export type MemoryStagingEvidence = { - id: string; - kind: string; - entry_range: [number, number] | null; - origin?: MemoryEvidenceOrigin | null; - excerpt: string | null; - summary: string | null; -}; +export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, }; -export type MemorySourceEvidenceRef = { - session_id: string | null; - segment_id: string | null; - entry_range: [number, number] | null; - evidence_id: string | null; - origin?: MemoryEvidenceOrigin | null; - evidence_kind: string | null; - label: string | null; - summary: string | null; -}; +export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, }; -export type MemoryStagingRecord = { - schema_version: number; - id: string; - extract_run_id: string; - source: MemorySourceRef; - kind: MemoryCandidateKind; - claim: string; - why_useful: string; - staleness: string | null; - evidence: Array; - source_refs: Array; -}; +export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array, source_refs: Array, }; -export type MemoryStagingEntry = { - id: string; - byte_len: number; - record: MemoryStagingRecord; -}; +export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, }; -export type MemoryStagingListResponse = { - limit: number; - returned_count: number; - total_valid_count: number; - invalid_count: number; - truncated: boolean; - order: string; - record_authority: string; - items: Array; - diagnostics: Array; -}; +export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array, diagnostics: Array, }; + +export type SubjektivSubjectState = "active" | "retired"; + +export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; + +export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; + +export type SubjektivMemoryState = "active" | "resolved" | "retracted"; + +export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; + +export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; + +export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; + +export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; + +export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; + +export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; + +export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, }; + +export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, }; + +export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; + +export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; + +export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; + +export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 59b5e68a2..2ea35b98e 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 9f33fc5dd..5d6d6539e 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index a1d54d8ed..6438d4f38 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index f0cec690a..bf78b1ad7 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 467a8030f..f1fce0990 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -96,11 +96,7 @@ function optional( label: string, parse: (item: unknown, label: string) => T, ): T | null | undefined { - return value === undefined - ? undefined - : value === null - ? null - : parse(value, label); + return value === undefined ? undefined : value === null ? null : parse(value, label); } function diagnostic(value: unknown, label: string): Diagnostic { @@ -388,17 +384,13 @@ export function parseWorkerSummary( `${label}.singleton_key`, string, ), - tags: item.tags === undefined - ? [] - : array(item.tags, `${label}.tags`, string), + tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string), workspace: workspaceSummary(item.workspace, `${label}.workspace`), availability, state: string(item.state, `${label}.state`), worker_state: workerState, last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string), - pinned: item.pinned === undefined - ? false - : boolean(item.pinned, `${label}.pinned`), + pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`), retention_state: item.retention_state === undefined ? "" : string(item.retention_state, `${label}.retention_state`), @@ -406,13 +398,11 @@ export function parseWorkerSummary( item.implementation, `${label}.implementation`, ), - workdir_attachments: item.workdir_attachments === undefined - ? undefined - : array( - item.workdir_attachments, - `${label}.workdir_attachments`, - workerWorkdirAttachment, - ), + workdir_attachments: item.workdir_attachments === undefined ? undefined : array( + item.workdir_attachments, + `${label}.workdir_attachments`, + workerWorkdirAttachment, + ), diagnostics: item.diagnostics === undefined ? [] : array(item.diagnostics, `${label}.diagnostics`, diagnostic), @@ -622,17 +612,22 @@ function workerSummary( item.implementation, `${label}.implementation`, ), - workdir_attachments: item.workdir_attachments === undefined - ? undefined - : array( - item.workdir_attachments, - `${label}.workdir_attachments`, - runtimeWorkdirAttachment, - ), + workdir_attachments: item.workdir_attachments === undefined ? undefined : array( + item.workdir_attachments, + `${label}.workdir_attachments`, + runtimeWorkdirAttachment, + ), diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic), }; } +export function parseWorkerLaunchWorkerSummary( + value: unknown, + label = "Worker launch summary", +): WorkerLaunchWorkerSummary { + return workerSummary(value, label); +} + export function parseBrowserCreateWorkerResponse( value: unknown, ): BrowserCreateWorkerResponse { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 6c806c73b..3c51b1ec5 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -448,27 +448,39 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () const memoryNav = await Deno.readTextFile( new URL("../sidebar/MemoryNavSection.svelte", import.meta.url), ); - const memoryDocumentLoad = await Deno.readTextFile( + const subjectIndexLoad = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url, ), ); - const memoryDocumentPage = await Deno.readTextFile( + const subjectIndexPage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/+page.svelte", import.meta.url, ), ); - const memoryStagingLoad = await Deno.readTextFile( + const subjectPageLoad = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/staging/+page.ts", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.ts", import.meta.url, ), ); - const memoryStagingPage = await Deno.readTextFile( + const subjectPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte", + import.meta.url, + ), + ); + const memoryDetailLoad = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts", + import.meta.url, + ), + ); + const memoryDetailPage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte", import.meta.url, ), ); @@ -478,35 +490,44 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () memoryNav.includes( '', ) && - memoryNav.includes("Document") && + memoryNav.includes("Subjects") && + memoryNav.includes("currentPath.startsWith(`${subjectsHref}/`)") && memoryNav.includes("sidebar-nav-section--category") && memoryNav.includes('class="sidebar-link"') && - memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') && - memoryNav.includes("Staging") && - !memoryNav.includes("item-meta") && - !memoryNav.includes("durable workspace memory") && - !memoryNav.includes("pending extraction candidates"), - "Memory sidebar section should show Document and Staging as compact single-line links", + !memoryNav.includes("Staging") && + !memoryNav.includes("Document"), + "Memory sidebar section should expose one Subjects link for the full nested route tree", ); assert( - memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") && - memoryDocumentLoad.includes('"/memory"') && - memoryDocumentPage.includes("DocumentMarkdown from") && - memoryDocumentPage.includes("Read-only") && - memoryDocumentPage.includes("data.memory.data.body_md") && - memoryDocumentPage.includes("data.memory.data.updated_at") && - memoryDocumentPage.includes('role="alert"') && - !memoryDocumentPage.includes('
Subjects") &&
+      subjectIndexPage.includes("subject.store_revision") &&
+      subjectIndexPage.includes('data-memory-view="subjects"'),
+    "Memory product entry should list explicit typed subjects",
   );
   assert(
-    memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
-      memoryStagingLoad.includes('"/memory/staging"') &&
-      memoryStagingPage.includes("Memory Staging") &&
-      memoryStagingPage.includes("Workspace Server memory authority") &&
-      memoryStagingPage.includes("data.staging.data.invalid_count") &&
-      memoryStagingPage.includes("entry.record.evidence"),
-    "Memory Staging page should read the scoped API and expose staged records without mutation controls",
+    subjectPageLoad.includes("`${subjectPath}/surface`") &&
+      subjectPageLoad.includes("`${subjectPath}/memories`") &&
+      subjectPage.includes("DocumentMarkdown from") &&
+      subjectPage.includes("Resident surface") &&
+      subjectPage.includes("Current Memories") &&
+      subjectPage.includes("surface?.availability === 'ready'") &&
+      subjectPage.includes("surface?.availability === 'stale'") &&
+      subjectPage.includes("data-surface-ready-empty") &&
+      !subjectPage.includes("overflow-y"),
+    "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
+  );
+  assert(
+    memoryDetailLoad.includes("`${memoryPath}/revisions`") &&
+      memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") &&
+      memoryDetailPage.includes("Candidate provenance") &&
+      memoryDetailPage.includes("Source refs") &&
+      memoryDetailPage.includes("Derived from") &&
+      memoryDetailPage.includes("Revision history") &&
+      memoryDetailPage.includes("DocumentMarkdown from") &&
+      !memoryDetailPage.includes("overflow-y"),
+    "Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls",
   );
 });
 
@@ -719,8 +740,12 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
 
 Deno.test("Console spacing and text metrics use existing design tokens", async () => {
   const files = [
-    "ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
-    "ConsoleTasks.svelte", "WorkerRunStatus.svelte", "WorkerContextStatus.svelte",
+    "ComposerInput.svelte",
+    "ConsoleLineItem.svelte",
+    "RichMarkdown.svelte",
+    "ConsoleTasks.svelte",
+    "WorkerRunStatus.svelte",
+    "WorkerContextStatus.svelte",
     "ConsoleDisplayState.svelte",
     "ConsoleTurnNavigation.svelte",
     "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
@@ -729,7 +754,8 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
     const source = await Deno.readTextFile(new URL(file, import.meta.url));
     const css = source.split("
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
index 65205285c..49f9c9148 100644
--- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
+++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
@@ -1,20 +1,19 @@
-import { MEMORY_API_LIMITS } from "$lib/generated/memory-api";
 import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
-import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
+import {
+  MEMORY_API_LOAD_POLICY,
+  parseSubjektivSubjectListResponse,
+} from "$lib/workspace/memory/api";
 import type { PageLoad } from "./$types";
 
 export const load: PageLoad = async ({ fetch, params }) => {
   return {
     workspaceId: params.workspaceId,
-    memory: await loadJson(
+    subjects: await loadJson(
       fetch,
-      workspaceApiPath(params.workspaceId, "/memory"),
+      `${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
       undefined,
-      parseMemoryDocumentResponse,
-      {
-        diagnosticLabel: "Memory API",
-        maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
-      },
+      parseSubjektivSubjectListResponse,
+      MEMORY_API_LOAD_POLICY,
     ),
   };
 };
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte
new file mode 100644
index 000000000..8330b06ff
--- /dev/null
+++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte
@@ -0,0 +1,470 @@
+
+
+
+  {data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace
+  
+
+
+
+ ← All subjects + +
+
+

Memory subject

+

{data.subject.data?.role ?? data.subjectId}

+ {data.subjectId} +
+ Read-only +
+ + {#if data.subject.data} +
+
Role
{data.subject.data.role}
+
State
{data.subject.data.state}
+
Store revision
{data.subject.data.store_revision}
+
Updated
+
Current worker
{data.subject.data.current_worker?.display_name ?? 'None'}
+
+ {:else if data.subject.error} + + {:else} +

Subject details are unavailable.

+ {/if} + +
+
+
+

Resident context

+

Resident surface

+
+ {#if surface} + {surface.availability} + {/if} +
+ + {#if surface?.availability === 'ready' && snapshot} +
+ Built from store revision {snapshot.built_from_store_revision} + + + + {snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'} +
+ {#if snapshot.body_md.trim().length === 0} +
+ Resident surface is ready and empty. +

This subject currently has no resident context to display.

+
+ {:else} +
+ +
+ {/if} +
+ Surface provenance +
+
Snapshot id
{snapshot.snapshot_id}
+
+
Memory refs
+
+ {#if snapshot.memory_refs.length === 0} + None + {:else} + + {/if} +
+
+
+
+ {:else if surface?.availability === 'ungenerated'} +
+ Resident surface has not been generated. +

Committed Memories remain available below.

+
+ {:else if surface?.availability === 'stale'} +
+ Resident surface is stale. +

The latest committed Memories are not represented by a publishable snapshot.

+
+ {:else if surface?.availability === 'failed'} + + {:else if data.surface.error} + + {:else} +

Resident surface data is unavailable.

+ {/if} +
+ +
+
+
+

Committed records

+

Current Memories

+
+ {#if data.memories.data}{memories.length}{data.memories.data.has_more ? '+' : ''}{/if} +
+ + {#if data.memories.data} + {#if memories.length === 0} +
+ No committed Memories. +

This subject has no current Memory revisions.

+
+ {:else} + + {#if data.memories.data.has_more} +

More current Memories are available after this bounded page.

+ {/if} + {/if} + {:else if data.memories.error} + + {:else} +

Current Memory data is unavailable.

+ {/if} +
+
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.ts new file mode 100644 index 000000000..e461c80b8 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.ts @@ -0,0 +1,44 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivMemoryQueryResponse, + parseSubjektivResidentSurfaceResponse, + parseSubjektivSubjectResponse, +} from "$lib/workspace/memory/api"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const subjectId = params.subjectId; + const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; + const [subject, surface, memories] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(params.workspaceId, subjectPath), + undefined, + (value) => parseSubjektivSubjectResponse(value, subjectId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + workspaceApiPath(params.workspaceId, `${subjectPath}/surface`), + undefined, + (value) => parseSubjektivResidentSurfaceResponse(value, subjectId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)}?limit=100`, + undefined, + parseSubjektivMemoryQueryResponse, + MEMORY_API_LOAD_POLICY, + ), + ]); + + return { + workspaceId: params.workspaceId, + subjectId, + subject, + surface, + memories, + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte new file mode 100644 index 000000000..5cacdf6c6 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -0,0 +1,626 @@ + + + + {memory?.claim ?? data.memoryId} · Memory · Yoi Workspace + + + +
+ ← {data.subject.data?.role ?? 'Subject'} + + {#if memory} +
+
+
+ {memory.state} + {label(memory.kind)} + {#if memory.revision !== memory.current_revision}Historical revision{/if} +
+

{memory.claim}

+ {memory.memory_id} +
+ Read-only +
+ +
+
Revision
{memory.revision} of {memory.current_revision}
+
State
{memory.state}
+
Kind
{label(memory.kind)}
+
Created
+
Updated
+
+ +
+

Memory

+

Why useful: {memory.why_useful}

+ {#if memory.staleness}

Staleness: {memory.staleness}

{/if} +

Change reason: {memory.change_reason}

+ + {#if memory.body_md.trim().length === 0} +
This Memory body is empty.
+ {:else} +
+ +
+ {/if} + {#if bodyContinuationHref()} +
+

This bounded body segment continues at line {memory.body_next_offset}, byte {memory.body_next_byte_offset}.

+ View next body segment +
+ {/if} +
+ +
+
+

Audit context

Candidate provenance

+ {memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'} +
+ + {#if memory.source_candidates.length === 0} +

No candidate evidence is included on this page.

+ {:else} +
+ {#each memory.source_candidates as candidate, candidateIndex (candidate.candidate_id)} +
+
+

Candidate

+ {candidate.candidate_id} +
+ +
+

Evidence {candidate.evidence.length} of {candidate.evidence_total}

+ {#if candidate.evidence.length === 0} +

No evidence anchors on this page.

+ {:else} +
    + {#each candidate.evidence as evidence (evidence.id)} +
  • +
    {evidence.id}{label(evidence.kind)}
    +
    +
    Entry range
    {rangeLabel(evidence.entry_range)}
    +
    Origin
    {originLabel(evidence.origin)}
    +
    + {#if evidence.summary}

    {evidence.summary}

    {/if} + {#if evidence.excerpt}
    {evidence.excerpt}
    {/if} +
  • + {/each} +
+ {/if} + {#if candidate.evidence_truncated}

Additional evidence anchors are available.

{/if} +
+ +
+

Source refs {candidate.source_refs.length} of {candidate.source_refs_total}

+ {#if candidate.source_refs.length === 0} +

No source refs on this page.

+ {:else} +
    + {#each candidate.source_refs as reference, index (`${candidate.candidate_id}:source:${index}`)} +
  • + {sourceRefTitle(reference)} +
    +
    Session
    {reference.session_id ?? 'None'}
    +
    Segment
    {reference.segment_id ?? 'None'}
    +
    Entry range
    {rangeLabel(reference.entry_range)}
    +
    Evidence id
    {reference.evidence_id ?? 'None'}
    +
    Evidence kind
    {reference.evidence_kind ? label(reference.evidence_kind) : 'None'}
    +
    Origin
    {originLabel(reference.origin)}
    +
    + {#if reference.summary}

    {reference.summary}

    {/if} +
  • + {/each} +
+ {/if} + {#if candidate.source_refs_truncated}

Additional source refs are available.

{/if} +
+
+ {/each} +
+ {/if} + {#if evidenceContinuationHref()} +
+

Candidate evidence continues on another bounded page.

+ View next evidence page +
+ {/if} +
+ +
+

Derived from

{memory.derived_from.length}
+ {#if memory.derived_from.length === 0} +

No derivation references.

+ {:else} + + {/if} +
+ +
+

Immutable record

Revision history

{#if data.revisions.data}{revisions.length}{data.revisions.data.has_more ? '+' : ''}{/if}
+ {#if data.revisions.data} + {#if revisions.length === 0} +

No revision history is available.

+ {:else} +
    + {#each revisions as revision (revision.revision)} +
  1. + +
    Revision {revision.revision}{revision.state}{label(revision.kind)}
    +

    {revision.claim}

    + {revision.change_reason} · +
    +
  2. + {/each} +
+ {#if data.revisions.data.has_more}

More immutable revisions are available after this bounded page.

{/if} + {/if} + {:else if data.revisions.error} + + {:else} +

Revision history data is unavailable.

+ {/if} +
+ {:else if data.memory.error} +
+

Committed Memory

{data.memoryId}

+ Read-only +
+ + {:else} +

{data.memoryId}

+

Memory data is unavailable.

+ {/if} +
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts new file mode 100644 index 000000000..e4b492583 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts @@ -0,0 +1,82 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivMemoryListRevisionsResponse, + parseSubjektivMemoryReadResponse, + parseSubjektivSubjectResponse, +} from "$lib/workspace/memory/api"; +import type { PageLoad } from "./$types"; + +const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/; + +function safeIntegerQuery( + source: URLSearchParams, + key: string, + positive = false, +): string | null { + const value = source.get(key); + if (value === null || !SAFE_INTEGER_TEXT.test(value)) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || (positive ? parsed < 1 : parsed < 0)) { + return null; + } + return String(parsed); +} + +export const load: PageLoad = async ({ fetch, params, url }) => { + const subjectId = params.subjectId; + const memoryId = params.memoryId; + const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; + const memoryPath = `${subjectPath}/memories/${encodeURIComponent(memoryId)}`; + const detailQuery = new URLSearchParams({ limit: "1000" }); + for ( + const [key, positive] of [ + ["revision", true], + ["offset", false], + ["byte_offset", false], + ] as const + ) { + const value = safeIntegerQuery(url.searchParams, key, positive); + if (value !== null) detailQuery.set(key, value); + } + const evidenceCursor = url.searchParams.get("evidence_cursor"); + if ( + evidenceCursor !== null && evidenceCursor.length > 0 && + new TextEncoder().encode(evidenceCursor).byteLength <= 512 + ) { + detailQuery.set("evidence_cursor", evidenceCursor); + } + + const [subject, memory, revisions] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(params.workspaceId, subjectPath), + undefined, + (value) => parseSubjektivSubjectResponse(value, subjectId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, memoryPath)}?${detailQuery}`, + undefined, + (value) => parseSubjektivMemoryReadResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)}?limit=100`, + undefined, + (value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + ]); + + return { + workspaceId: params.workspaceId, + subjectId, + memoryId, + subject, + memory, + revisions, + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index f1152218d..f3e36bda4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -2,75 +2,250 @@ import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; import { afterEach, expect, test } from "vitest"; -import MemoryPage from "./+page.svelte"; -import type { PageProps } from "./$types"; - -function pageProps(memory: { - data: { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; - } | null; - error: string | null; -}): PageProps { - return { - data: { workspaceId: "workspace-1", memory }, - params: { workspaceId: "workspace-1" }, - } as unknown as PageProps; -} +import SubjectIndexPage from "./+page.svelte"; +import SubjectPage from "./[subjectId]/+page.svelte"; +import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte"; afterEach(cleanup); -test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => { - const { container } = render( - MemoryPage, - pageProps({ - data: { - body_md: "# Durable context\n\nRead the **current** document.", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: 48, - record_source: "sqlite_workspace_authority.memory_document", - }, - error: null, - }), - ); +const result = (data: T | null, error: string | null = null) => ({ + data, + error, +}); + +function subject() { + return { + id: "subject-1", + role: "Release coordinator", + state: "active" as const, + store_revision: 9, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + }; +} + +test("renders the subject index as the Memory product entry", () => { + render(SubjectIndexPage, { + data: { + workspaceId: "workspace-1", + subjects: result({ limit: 100, items: [subject()], has_more: false }), + }, + } as never); + + expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not + .toBeNull(); + expect(screen.getByRole("link", { name: /Release coordinator/ })).not + .toBeNull(); + expect(screen.getByText("subject-1")).not.toBeNull(); + expect(screen.queryByText("Staging")).toBeNull(); +}); + +test("renders a ready resident surface and committed Memory lifecycle states", async () => { + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + surface: result({ + subject_id: "subject-1", + availability: "ready" as const, + snapshot: { + snapshot_id: "snapshot-1", + body_md: "# Resident context\n\nUse the **current** committed record.", + memory_refs: [{ memory_id: "memory-1", revision: 3 }], + built_from_store_revision: 9, + created_at: "2026-01-02T03:04:05Z", + }, + }), + memories: result({ + items: [ + { + id: "memory-1", + revision: 3, + kind: "decision" as const, + state: "active" as const, + claim: "Current decision", + excerpt: "Current excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + { + id: "memory-2", + revision: 2, + kind: "lesson" as const, + state: "resolved" as const, + claim: "Resolved lesson", + excerpt: "Resolved excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + { + id: "memory-3", + revision: 1, + kind: "constraint" as const, + state: "retracted" as const, + claim: "Retracted constraint", + excerpt: "Retracted excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + ], + has_more: false, + }), + }, + } as never); await waitFor(() => { - expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) + .not.toBeNull(); }); - expect(screen.getByText("Read-only")).not.toBeNull(); - expect(screen.getByText("Document details")).not.toBeNull(); - expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false); - expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull(); - expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull(); + expect(screen.getByRole("article", { name: "Resident surface" })).not + .toBeNull(); + expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan( + 0, + ); + expect(screen.getByText("resolved", { exact: true })).not.toBeNull(); + expect(screen.getByText("retracted", { exact: true })).not.toBeNull(); }); -test("distinguishes an empty document from request failure and unavailable data", async () => { - const view = render( - MemoryPage, - pageProps({ +test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { + const base = { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + memories: result({ items: [], has_more: false }), + }; + const view = render(SubjectPage, { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "ready" as const, + snapshot: { + snapshot_id: "snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 9, + created_at: "2026-01-02T03:04:05Z", + }, + }), + }, + } as never); + expect(screen.getByText("Resident surface is ready and empty.")).not + .toBeNull(); + + await view.rerender( + { data: { - body_md: " \n", + ...base, + surface: result({ + subject_id: "subject-1", + availability: "stale" as const, + }), + }, + } as never, + ); + expect(screen.getByText("Resident surface is stale.")).not.toBeNull(); + + await view.rerender( + { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "failed" as const, + }), + }, + } as never, + ); + expect(screen.getByRole("alert").textContent).toContain("generation failed"); + + await view.rerender({ data: { ...base, surface: result(null) } } as never); + expect(screen.getByText("Resident surface data is unavailable.")).not + .toBeNull(); + + await view.rerender( + { + data: { ...base, surface: result(null, "Memory API request failed") }, + } as never, + ); + expect(screen.getByRole("alert").textContent).toContain( + "Resident surface unavailable", + ); +}); + +test("renders committed Memory detail, provenance, derivation, and immutable revisions", async () => { + render(MemoryDetailPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + memoryId: "memory-1", + subject: result(subject()), + memory: result({ + memory_id: "memory-1", + revision: 2, + current_revision: 3, + kind: "decision" as const, + state: "resolved" as const, + claim: "Keep provenance typed", + body_md: "# Committed detail\n\nRead-only body.", + why_useful: "Prevents origin loss.", + change_reason: "Resolved after implementation.", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", - bytes: 3, - record_source: "fixture", - }, - error: null, - }), - ); - expect(screen.getByRole("status").textContent).toContain("Memory document is empty."); - expect(screen.queryByRole("alert")).toBeNull(); + body_offset: 0, + body_byte_offset: 0, + body_truncated: false, + source_candidate_ids: ["candidate-1"], + source_candidates: [{ + candidate_id: "candidate-1", + evidence: [{ + id: "evidence-1", + kind: "message", + summary: "Explicit decision.", + }], + evidence_total: 1, + evidence_truncated: false, + source_refs: [{ + session_id: "session-1", + segment_id: "segment-1", + evidence_id: "evidence-1", + label: "Decision discussion", + }], + source_refs_total: 1, + source_refs_truncated: false, + }], + derived_from: [{ memory_id: "memory-parent", revision: 4 }], + evidence_has_more: false, + }), + revisions: result({ + memory_id: "memory-1", + current_revision: 3, + items: [{ + revision: 3, + kind: "decision" as const, + state: "active" as const, + claim: "Current claim", + change_reason: "Clarified", + updated_at: "2026-01-03T00:00:00Z", + }, { + revision: 2, + kind: "decision" as const, + state: "resolved" as const, + claim: "Earlier claim", + change_reason: "Resolved", + updated_at: "2026-01-02T00:00:00Z", + }], + has_more: false, + }), + }, + } as never); - await view.rerender(pageProps({ data: null, error: "Memory API request failed" })); - expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable."); - expect(screen.getByRole("alert").textContent).toContain("Memory API request failed"); - expect(screen.queryByText("Memory document is empty.")).toBeNull(); - - await view.rerender(pageProps({ data: null, error: null })); - expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable."); - expect(screen.queryByRole("alert")).toBeNull(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Committed detail", level: 1 })) + .not.toBeNull(); + }); + expect(screen.getByText("candidate-1")).not.toBeNull(); + expect(screen.getByText("Decision discussion")).not.toBeNull(); + expect(screen.getByText("memory-parent")).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Revision history" })).not + .toBeNull(); + expect(screen.getByText("Historical revision")).not.toBeNull(); }); diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 8d01e387b..b4eb3d88b 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -3,12 +3,29 @@ declare const Deno: { }; import { - parseMemoryDocumentResponse, parseMemoryStagingListResponse, + parseSubjektivMemoryListRevisionsResponse, + parseSubjektivMemoryQueryResponse, + parseSubjektivMemoryReadResponse, + parseSubjektivResidentSurfaceResponse, + parseSubjektivSubjectListResponse, + parseSubjektivSubjectResponse, } from "../src/lib/workspace/memory/api.ts"; +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, stable(item)]), + ); + } + return value; +} + function assertEquals(actual: unknown, expected: unknown): void { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { + if (JSON.stringify(stable(actual)) !== JSON.stringify(stable(expected))) { throw new Error( `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, ); @@ -27,8 +44,386 @@ function assertThrows(fn: () => void, expectedMessage: string): void { throw new Error(`expected function to throw ${expectedMessage}`); } -function fixture(origin: Record) { +function subject(id = "subject-1") { return { + id, + role: "Release coordinator", + state: "active", + store_revision: 12, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T00:00:00Z", + }; +} + +function queryItem(id = "memory-1") { + return { + id, + revision: 3, + kind: "decision", + state: "active", + claim: "Keep provenance typed.", + excerpt: "Preserve source and derivation references.", + updated_at: "2026-09-02T03:04:05Z", + }; +} + +function detailFixture() { + const origin = { + kind: "flow_instruction", + workspace_id: "workspace-1", + runtime_id: "runtime-1", + worker_id: "worker-1", + flow_selector: "builtin:coder-review", + flow_definition_id: "flow-1", + flow_definition_revision: 7, + }; + return { + memory_id: "memory-1", + revision: 2, + current_revision: 3, + kind: "decision", + state: "resolved", + claim: "Keep provenance typed.", + body_md: "# Committed Memory\n\nBounded body segment.", + why_useful: "Prevents origin loss.", + staleness: null, + change_reason: "Resolved after implementation.", + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T03:04:05Z", + body_offset: 0, + body_byte_offset: 0, + body_next_offset: 2, + body_next_byte_offset: 0, + body_truncated: true, + source_candidate_ids: ["candidate-1"], + source_candidates: [{ + candidate_id: "candidate-1", + evidence: [{ + id: "evidence-1", + kind: "message", + entry_range: [10, 12], + origin, + excerpt: "Use exact DTOs.", + summary: "The decision was explicit.", + }], + evidence_total: 2, + evidence_truncated: true, + source_refs: [{ + session_id: "session-1", + segment_id: "segment-1", + entry_range: [10, 12], + evidence_id: "evidence-1", + origin, + evidence_kind: "message", + label: "Decision discussion", + summary: "Bounded host summary.", + }], + source_refs_total: 2, + source_refs_truncated: true, + }], + derived_from: [{ memory_id: "memory-parent", revision: 4 }], + evidence_next_cursor: "evidence-page-2", + evidence_has_more: true, + }; +} + +Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bounds", () => { + assertEquals( + parseSubjektivSubjectResponse(subject(), "subject-1"), + subject(), + ); + assertThrows( + () => parseSubjektivSubjectResponse(subject(), "another-subject"), + "identity does not match", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), state: "paused" }), + "unknown Subject state", + ); + assertThrows( + () => + parseSubjektivSubjectResponse({ + ...subject(), + store_revision: Number.MAX_SAFE_INTEGER + 1, + }), + "safe integer", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), id: "x".repeat(513) }), + "bounded identifier", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), future: true }), + "unknown field", + ); + assertThrows( + () => + parseSubjektivSubjectListResponse({ + limit: 100, + items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)), + has_more: false, + }), + "bounded array", + ); +}); + +Deno.test("Subject list parser preserves the exact bounded response", () => { + const response = { limit: 100, items: [subject()], has_more: false }; + assertEquals(parseSubjektivSubjectListResponse(response), response); + assertThrows( + () => + parseSubjektivSubjectListResponse({ + limit: 100, + items: [subject("duplicate"), subject("duplicate")], + has_more: false, + }), + "Subject ids must be unique", + ); +}); + +Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => { + const readyEmpty = { + subject_id: "subject-1", + availability: "ready", + snapshot: { + snapshot_id: "snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-09-02T00:00:00Z", + }, + }; + assertEquals( + parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-1"), + readyEmpty, + ); + assertEquals( + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "ungenerated", + }), + { subject_id: "subject-1", availability: "ungenerated" }, + ); + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "ready", + }), + "requires a snapshot", + ); + for (const availability of ["ungenerated", "stale", "failed"]) { + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + ...readyEmpty, + availability, + }), + "must not include a snapshot", + ); + } + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "refreshing", + }), + "unknown resident surface availability", + ); + assertThrows( + () => parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-2"), + "identity does not match", + ); +}); + +Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, cursors, and oversized pages", () => { + const response = { + items: [queryItem()], + has_more: true, + next_cursor: "memory-page-2", + }; + assertEquals(parseSubjektivMemoryQueryResponse(response), response); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), state: "pending" }], + }), + "unknown Memory state", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), kind: "future_kind" }], + }), + "unknown Memory candidate kind", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), revision: 0 }], + }), + "positive safe integer", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + items: [queryItem()], + has_more: true, + }), + "cursor does not match", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)), + has_more: false, + }), + "bounded array", + ); +}); + +Deno.test("Memory detail parser preserves body/evidence continuation, sources, origins, and derivation", () => { + const fixture = detailFixture(); + const parsed = parseSubjektivMemoryReadResponse(fixture, "memory-1"); + assertEquals(parsed, fixture); + assertEquals(parsed.body_next_offset, 2); + assertEquals(parsed.body_next_byte_offset, 0); + assertEquals(parsed.evidence_next_cursor, "evidence-page-2"); + assertEquals( + parsed.source_candidates[0].evidence[0].origin, + fixture.source_candidates[0].evidence[0].origin, + ); + assertEquals( + parsed.source_candidates[0].source_refs, + fixture.source_candidates[0].source_refs, + ); + assertEquals(parsed.derived_from, [{ + memory_id: "memory-parent", + revision: 4, + }]); +}); + +Deno.test("Memory detail parser fails closed on identity, variants, bounds, and continuation mismatch", () => { + const fixture = detailFixture(); + assertThrows( + () => parseSubjektivMemoryReadResponse(fixture, "memory-2"), + "identity does not match", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + current_revision: Number.MAX_SAFE_INTEGER + 1, + }), + "safe integer", + ); + const missingBodyContinuation = structuredClone(fixture) as Record< + string, + unknown + >; + delete missingBodyContinuation.body_next_byte_offset; + assertThrows( + () => parseSubjektivMemoryReadResponse(missingBodyContinuation), + "body continuation fields", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + source_candidate_ids: ["different-candidate"], + }), + "source candidate ids do not match", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + source_candidates: [{ + ...fixture.source_candidates[0], + evidence: [{ + ...fixture.source_candidates[0].evidence[0], + entry_range: [12, 10], + }], + }], + }), + "must be ordered", + ); + const unknownOrigin = structuredClone(fixture); + unknownOrigin.source_candidates[0].evidence[0].origin.kind = "future_origin"; + assertThrows( + () => parseSubjektivMemoryReadResponse(unknownOrigin), + "unknown Memory evidence origin kind", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + evidence_has_more: false, + }), + "cursor does not match", + ); +}); + +Deno.test("Memory revision parser preserves immutable states and checks response identity", () => { + const response = { + memory_id: "memory-1", + current_revision: 3, + items: [{ + revision: 3, + kind: "decision", + state: "active", + claim: "Current claim.", + change_reason: "Clarified wording.", + updated_at: "2026-09-03T00:00:00Z", + }, { + revision: 2, + kind: "decision", + state: "resolved", + claim: "Earlier claim.", + change_reason: "Resolved.", + updated_at: "2026-09-02T00:00:00Z", + }, { + revision: 1, + kind: "decision", + state: "retracted", + claim: "Original claim.", + change_reason: "Retracted.", + updated_at: "2026-09-01T00:00:00Z", + }], + has_more: false, + }; + assertEquals( + parseSubjektivMemoryListRevisionsResponse(response, "memory-1"), + response, + ); + assertThrows( + () => parseSubjektivMemoryListRevisionsResponse(response, "memory-2"), + "identity does not match", + ); + assertThrows( + () => + parseSubjektivMemoryListRevisionsResponse({ + ...response, + items: [{ ...response.items[0], state: "archived" }], + }), + "unknown Memory state", + ); + assertThrows( + () => + parseSubjektivMemoryListRevisionsResponse({ + ...response, + items: [{ ...response.items[0], revision: 4 }], + }), + "future revision", + ); +}); + +Deno.test("Legacy staging parser remains strict for deprecated compatibility", () => { + const origin = { kind: "worker_input", worker_id: "worker-1" }; + const response = { limit: 100, returned_count: 1, total_valid_count: 1, @@ -70,97 +465,19 @@ function fixture(origin: Record) { }], diagnostics: [], }; -} - -Deno.test("Memory document response requires the generated DTO fields", () => { - assertEquals( - parseMemoryDocumentResponse({ - body_md: "# Memory\n", - created_at: "2026-09-01T00:00:00Z", - updated_at: "2026-09-01T00:00:00Z", - bytes: 9, - record_source: "sqlite_workspace_authority.memory_document", - }).bytes, - 9, - ); - assertThrows( - () => parseMemoryDocumentResponse({ body_md: "# Memory\n" }), - "missing a required field", - ); -}); - -for ( - const [kind, fields] of [ - ["human_input", { account_id: "account-1" }], - [ - "worker_input", - { - workspace_id: "workspace-1", - runtime_id: "runtime-1", - worker_id: "worker-1", - }, - ], - ["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }], - ["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }], - ["legacy_unknown", {}], - ] as const -) { - Deno.test(`Memory staging parser preserves ${kind} origin`, () => { - const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields })); - assertEquals(parsed.items[0].record.evidence[0].origin, { - kind, - ...fields, - }); - assertEquals(parsed.items[0].record.source_refs[0].origin, { - kind, - ...fields, - }); - }); -} - -Deno.test("Memory staging parser preserves Flow origin fields", () => { - const origin = { - kind: "flow_instruction" as const, - workspace_id: "workspace-1", - runtime_id: "runtime-1", - worker_id: "worker-1", - flow_selector: "builtin:coder-review", - flow_definition_id: "flow-1", - flow_definition_revision: 7, - }; - const parsed = parseMemoryStagingListResponse(fixture(origin)); - assertEquals(parsed.items[0].record.source_refs[0].origin, origin); -}); - -Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => { - assertThrows( - () => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })), - "unknown Memory evidence origin kind", - ); + assertEquals(parseMemoryStagingListResponse(response), response); assertThrows( () => - parseMemoryStagingListResponse( - fixture({ kind: "human_input", future_field: "must not be accepted" }), - ), - "unknown field", - ); -}); - -Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => { - const malformed = fixture({ kind: "legacy_unknown" }); - malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [ - number, - number, - ]; - assertThrows( - () => parseMemoryStagingListResponse(malformed), - "two-item entry range", - ); - assertThrows( - () => - parseMemoryStagingListResponse( - fixture({ kind: "worker_input", worker_id: "x".repeat(513) }), - ), - "exceeds the Memory origin limit", + parseMemoryStagingListResponse({ + ...response, + items: [{ + ...response.items[0], + record: { + ...response.items[0].record, + source: { segment_id: "segment-1", range: [20, 10] }, + }, + }], + }), + "must be ordered", ); }); From 13bce155aa7b918d65d7461d24a9a546b999f20b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 3 Oct 2026 01:18:24 +0900 Subject: [PATCH 31/31] fix subject authority and bounded memory browsing --- crates/client/src/workspace_product.rs | 5 +- crates/manifest/src/lib.rs | 33 +- crates/runtime-api/src/lib.rs | 4 + crates/server-api/src/lib.rs | 6 +- crates/worker-runtime/src/catalog.rs | 4 + crates/worker-runtime/src/http_server.rs | 2 + crates/worker-runtime/src/runtime.rs | 1 + crates/worker-runtime/src/worker_backend.rs | 128 ++++++-- crates/worker/src/feature/builtin/memory.rs | 6 +- .../src/feature/builtin/memory_lifecycle.rs | 2 +- .../builtin/memory_surface_lifecycle.rs | 6 + .../src/feature/builtin/subjektiv_memory.rs | 2 +- .../src/feature/builtin/subjektiv_session.rs | 2 +- crates/worker/src/spawn/tool.rs | 75 +++-- crates/worker/tests/controller_test.rs | 66 ++-- crates/workspace-server/src/hosts.rs | 8 + .../src/runtime_subscription_tests.rs | 1 + crates/workspace-server/src/server.rs | 289 +++++++++++++++++- crates/workspace-server/src/store.rs | 1 + crates/workspace-server/src/subjektiv.rs | 52 +++- docs/development/subjektiv-product-cutover.md | 2 +- openapi/server-api.json | 14 +- .../memory_document_fixture_server.ts | 87 ++++-- .../memory_document_shell_test.ts | 47 ++- .../src/lib/generated/companion-api.ts | 2 +- web/workspace/src/lib/generated/memory-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- web/workspace/src/lib/workspace/memory/api.ts | 42 ++- .../w/[workspaceId]/memory/+page.svelte | 26 +- .../routes/w/[workspaceId]/memory/+page.ts | 17 +- .../memory/[subjectId]/+page.svelte | 29 +- .../[workspaceId]/memory/[subjectId]/+page.ts | 19 +- .../[subjectId]/[memoryId]/+page.svelte | 28 +- .../memory/[subjectId]/[memoryId]/+page.ts | 29 +- .../memory/memory-page.browser.spec.ts | 32 +- web/workspace/tests/memory-api.test.ts | 22 +- 40 files changed, 927 insertions(+), 174 deletions(-) diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 1032cda9c..cda30d257 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -422,7 +422,10 @@ impl BackendWorkspaceProductClient { client .subjektiv_subject_list( workspace_id, - SubjektivSubjectListQuery { limit: Some(limit) }, + SubjektivSubjectListQuery { + limit: Some(limit), + cursor: None, + }, ) .await }) diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 9986dad68..cd126532d 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -391,6 +391,12 @@ impl ResolvedSubjektivFeatureConfig { self.profile.enabled } + /// Returns whether the host attached this profile policy to trusted subject + /// authority. Profile enablement without a settings snapshot is inert. + pub fn execution_enabled(&self) -> bool { + self.profile.enabled && self.workspace_settings.is_some() + } + pub fn bind_workspace_settings( &mut self, settings: WorkspaceMemorySettingsSnapshot, @@ -413,9 +419,9 @@ impl ResolvedSubjektivFeatureConfig { } pub fn validate_execution(&self) -> Result<(), &'static str> { - if self.profile.enabled && self.workspace_settings.is_none() { - return Err("enabled subjektiv feature requires trusted Workspace settings"); - } + // `profile.enabled` is reusable policy. Only a host-bound settings + // snapshot activates subject execution; an ordinary Worker or Internal + // SubWorker with the same Profile remains inert. if !self.profile.enabled && self.workspace_settings.is_some() { return Err("disabled subjektiv feature must not carry Workspace settings"); } @@ -1719,6 +1725,27 @@ model_id = "claude-sonnet-4-20250514" assert!(disabled.bind_workspace_settings(snapshot).is_err()); } + #[test] + fn resolved_subjektiv_policy_is_inert_until_trusted_attachment() { + let snapshot = WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }; + let mut enabled = ResolvedSubjektivFeatureConfig::default(); + enabled.profile.enabled = true; + assert!(enabled.validate_execution().is_ok()); + assert!(!enabled.execution_enabled()); + enabled.bind_workspace_settings(snapshot.clone()).unwrap(); + assert!(enabled.validate_execution().is_ok()); + assert!(enabled.execution_enabled()); + + let mut disabled = ResolvedSubjektivFeatureConfig::default(); + disabled.workspace_settings = Some(snapshot.clone()); + assert!(disabled.validate_execution().is_err()); + assert!(disabled.bind_workspace_settings(snapshot).is_err()); + } + #[test] fn current_manifest_rejects_legacy_top_level_memory_authority() { let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n"); diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index a10b33361..729deb98d 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -444,6 +444,10 @@ pub struct CreateWorkerRequest { pub workspace_api: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_settings: Option, + /// Backend-attested subject or consolidation attachment. Profile policy alone + /// never activates subjektiv for an ordinary Workspace Worker. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub subjektiv_attached: bool, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index f580ce4d6..d6c7d6535 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4605,6 +4605,8 @@ pub struct SubjektivSubjectListQuery { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = 100))] pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] @@ -4613,6 +4615,8 @@ pub struct SubjektivSubjectListResponse { #[schemars(range(min = 1, max = 100))] pub limit: usize, pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, pub has_more: bool, } @@ -9849,7 +9853,7 @@ pub fn memory_api_typescript() -> String { export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; -export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; +export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; export type SubjektivMemoryState = "active" | "resolved" | "retracted"; diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 40bb79f65..bf2bb4105 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -298,6 +298,10 @@ pub struct CreateWorkerRequest { /// Backend-authored immutable Workspace Memory settings snapshot. #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_settings: Option, + /// Backend-attested subject or consolidation attachment. A reusable Profile + /// may enable subjektiv policy, but only this host-owned bit activates it. + #[serde(default, skip_serializing_if = "is_false")] + pub subjektiv_attached: bool, } /// Last persisted Worker lifecycle status. diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 65af6324a..77b60defa 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -3102,6 +3102,7 @@ mod tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } @@ -4081,6 +4082,7 @@ mod ws_tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 477ec7523..ffcb6a5f2 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -5992,6 +5992,7 @@ mod tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 20eeed10d..1fabdf3a4 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -944,7 +944,7 @@ fn bind_workspace_memory_settings( request: &CreateWorkerRequest, ) -> Result<(), String> { let Some(snapshot) = request.memory_settings.as_ref() else { - if request.workspace_api.is_some() { + if request.workspace_api.is_some() || request.subjektiv_attached { return Err( "Workspace Worker request is missing its bound Memory settings snapshot" .to_string(), @@ -967,7 +967,12 @@ fn bind_workspace_memory_settings( .bind_workspace_settings(snapshot.clone()) .map_err(str::to_string)?; } - if manifest.feature.subjektiv.profile.enabled { + if request.subjektiv_attached { + if !manifest.feature.subjektiv.profile.enabled { + return Err( + "subject-attached Worker profile does not enable subjektiv policy".to_string(), + ); + } manifest .feature .subjektiv @@ -992,6 +997,12 @@ fn validate_worker_memory_settings( request: &CreateWorkerRequest, ) -> Result<(), String> { let Some(expected) = request.memory_settings.as_ref() else { + if request.subjektiv_attached { + return Err( + "subject-attached Worker restore is missing its trusted settings snapshot" + .to_string(), + ); + } return Ok(()); }; manifest @@ -1004,30 +1015,50 @@ fn validate_worker_memory_settings( .subjektiv .validate_execution() .map_err(str::to_string)?; - for (feature, enabled, actual) in [ - ( - "Memory", - manifest.feature.memory.profile.enabled, - manifest.feature.memory.workspace_settings(), - ), - ( - "subjektiv", - manifest.feature.subjektiv.profile.enabled, - manifest.feature.subjektiv.workspace_settings(), - ), - ] { - if enabled && actual.as_ref() != Some(expected) { - let actual = actual.ok_or_else(|| { - format!("Workspace Worker restored without its bound {feature} settings snapshot") + if manifest.feature.memory.profile.enabled + && manifest.feature.memory.workspace_settings().as_ref() != Some(expected) + { + let actual = manifest + .feature + .memory + .workspace_settings() + .ok_or_else(|| { + "Workspace Worker restored without its bound Memory settings snapshot".to_string() + })?; + return Err(format!( + "Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}", + expected.workspace_id, + expected.settings_revision, + actual.workspace_id, + actual.settings_revision + )); + } + let subjektiv_settings = manifest.feature.subjektiv.workspace_settings(); + if request.subjektiv_attached { + if !manifest.feature.subjektiv.profile.enabled { + return Err( + "subject-attached Worker restored with a Profile that disables subjektiv policy" + .to_string(), + ); + } + if subjektiv_settings.as_ref() != Some(expected) { + let actual = subjektiv_settings.ok_or_else(|| { + "subject-attached Worker restored without its bound subjektiv settings snapshot" + .to_string() })?; return Err(format!( - "Workspace Worker {feature} settings snapshot mismatch: expected {} revision {}, restored {} revision {}", + "Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}", expected.workspace_id, expected.settings_revision, actual.workspace_id, actual.settings_revision )); } + } else if subjektiv_settings.is_some() { + return Err( + "ordinary Workspace Worker restored with an unauthorized subjektiv attachment" + .to_string(), + ); } Ok(()) } @@ -4412,9 +4443,70 @@ mod tests { worker_observation_grants: Vec::new(), workspace_api: None, memory_settings: None, + subjektiv_attached: false, } } + #[test] + fn host_attachment_alone_activates_subjektiv_profile_policy() { + let manifest = || { + let mut manifest = WorkerManifest::from_toml( + r#" + [worker] + name = "subjektiv-attachment-test" + pwd = "./" + + [model] + scheme = "anthropic" + model_id = "test-model" + auth = { kind = "none" } + + [engine] + max_tokens = 100 + + [[scope.allow]] + target = "./" + permission = "read" + "#, + ) + .unwrap(); + manifest.feature.subjektiv.profile.enabled = true; + manifest.feature.subjektiv.profile.extraction.enabled = true; + manifest + }; + let settings = manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-subject".to_string(), + settings_revision: 3, + language: "English".to_string(), + }; + let mut ordinary_request = create_request("ordinary"); + ordinary_request.workspace_api = Some(WorkspaceApiRef { + workspace_id: settings.workspace_id.clone(), + base_url: "http://workspace.invalid".to_string(), + }); + ordinary_request.memory_settings = Some(settings.clone()); + let mut ordinary = manifest(); + bind_workspace_memory_settings(&mut ordinary, &ordinary_request).unwrap(); + assert!(ordinary.feature.subjektiv.profile.enabled); + assert!(!ordinary.feature.subjektiv.execution_enabled()); + validate_worker_memory_settings(&ordinary, &ordinary_request).unwrap(); + + let mut attached_request = ordinary_request.clone(); + attached_request.subjektiv_attached = true; + let mut attached = manifest(); + bind_workspace_memory_settings(&mut attached, &attached_request).unwrap(); + assert!(attached.feature.subjektiv.execution_enabled()); + assert_eq!( + attached.feature.subjektiv.workspace_settings(), + Some(settings.clone()) + ); + validate_worker_memory_settings(&attached, &attached_request).unwrap(); + assert!(validate_worker_memory_settings(&attached, &ordinary_request).is_err()); + + attached_request.memory_settings = None; + assert!(bind_workspace_memory_settings(&mut manifest(), &attached_request).is_err()); + } + fn git(path: &std::path::Path, args: &[&str]) { let status = Command::new("git") .arg("-C") diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index e7f6fd0fe..f4199d0d5 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -825,7 +825,7 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile( } pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool { - manifest.feature.subjektiv.profile.enabled + manifest.feature.subjektiv.execution_enabled() && !is_builtin_subjektiv_consolidation_profile(manifest) } @@ -890,10 +890,10 @@ impl SubjektivConsolidationFeatureInstallPlan { config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !config.profile.enabled || config.profile.extraction.enabled { + if !config.execution_enabled() || config.profile.extraction.enabled { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "subjektiv consolidation profile requires subject settings without automatic extraction", + "subjektiv consolidation profile requires trusted subject settings without automatic extraction", )); } let workspace_id = client.workspace_id().ok_or_else(|| { diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 01a64eb94..3720adb28 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -199,7 +199,7 @@ impl SubjektivLifecycleFeature { workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { + if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled { return Ok(None); } config diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index a314628a7..7e11ac8ee 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -109,6 +109,12 @@ impl SubjektivSurfaceLifecycleFeature { .subjektiv .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !manifest.feature.subjektiv.execution_enabled() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv surface generation requires trusted subject attachment", + )); + } if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 15c0cc9e1..4a958fddc 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -78,7 +78,7 @@ impl SubjektivMemoryFeature { capture: CommittedSessionCaptureHandle, client: Arc, ) -> std::io::Result> { - if !config.profile.enabled { + if !config.execution_enabled() { return Ok(None); } config diff --git a/crates/worker/src/feature/builtin/subjektiv_session.rs b/crates/worker/src/feature/builtin/subjektiv_session.rs index 3f9dfab14..e0fb4b8f1 100644 --- a/crates/worker/src/feature/builtin/subjektiv_session.rs +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -55,7 +55,7 @@ impl SubjektivSessionFeature { capture: CommittedSessionCaptureHandle, client: Arc, ) -> std::io::Result> { - if !config.profile.enabled { + if !config.execution_enabled() { return Ok(None); } config diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 0f7a389e1..55db47c2a 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -925,25 +925,30 @@ fn bind_child_memory_settings( parent: &manifest::WorkerManifest, child: &mut manifest::WorkerManifest, ) -> Result<(), String> { - if !child.feature.memory.profile.enabled { - return child + if child.feature.memory.profile.enabled { + let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| { + "enabled child Memory feature requires the parent's trusted Workspace settings snapshot" + .to_string() + })?; + child .feature .memory - .validate_execution() - .map_err(str::to_string); + .bind_workspace_settings(workspace_settings) + .map_err(str::to_string)?; } - let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| { - "enabled child Memory feature requires the parent's trusted Workspace settings snapshot" - .to_string() - })?; child .feature .memory - .bind_workspace_settings(workspace_settings) + .validate_execution() .map_err(str::to_string)?; + + // An Internal SubWorker has no Backend keyed-singleton lease of its own. + // Preserve reusable subjektiv profile policy but never copy the parent's + // trusted activation snapshot into the child. + child.feature.subjektiv.workspace_settings = None; child .feature - .memory + .subjektiv .validate_execution() .map_err(str::to_string) } @@ -1292,7 +1297,7 @@ enabled = false } #[tokio::test] - async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() { + async fn builtin_reviewer_spawn_from_subject_parent_is_inert_and_notifies_parent() { let runtime = TempDir::new().unwrap(); let workspace_root = runtime.path().join("project"); let bash_output_dir = runtime.path().join("bash-output"); @@ -1310,6 +1315,17 @@ enabled = false allow: vec![abs_rule(&workspace_root, Permission::Write)], deny: Vec::new(), }; + manifest.feature.subjektiv.profile.enabled = true; + manifest.feature.subjektiv.profile.extraction.enabled = true; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-test".to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap()); let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone()); let workspace_context = crate::worker::WorkerWorkspaceContext::with_client( @@ -1359,7 +1375,7 @@ enabled = false })); let input = serde_json::json!({ "name": "reviewer-child", - "profile": "project:reviewer", + "profile": "builtin:reviewer", "instruction": "role.reviewer", "task": "review immutable commit", "scope": [{ @@ -1409,6 +1425,14 @@ enabled = false record.installed_tools ); } + assert!( + record + .installed_tools + .iter() + .all(|name| !name.starts_with("Subjektiv")), + "Reviewer child must not install independently scoped subjektiv tools: {:?}", + record.installed_tools + ); assert!( !record .installed_tools @@ -1881,14 +1905,22 @@ enabled = false let temp = tempfile::tempdir().unwrap(); let mut parent = parent_manifest(temp.path(), None); parent.feature.memory.profile.enabled = true; + let settings = manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 4, + language: "日本語".to_string(), + }; parent .feature .memory - .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-1".to_string(), - settings_revision: 4, - language: "日本語".to_string(), - }) + .bind_workspace_settings(settings.clone()) + .unwrap(); + parent.feature.subjektiv.profile.enabled = true; + parent.feature.subjektiv.profile.extraction.enabled = true; + parent + .feature + .subjektiv + .bind_workspace_settings(settings) .unwrap(); let mut child = parent.clone(); child.feature.memory.workspace_settings = None; @@ -1898,6 +1930,15 @@ enabled = false child.feature.memory.workspace_settings(), parent.feature.memory.workspace_settings() ); + assert!(child.feature.subjektiv.profile.enabled); + assert!(!child.feature.subjektiv.execution_enabled()); + + let mut ordinary_parent = parent.clone(); + ordinary_parent.feature.subjektiv.workspace_settings = None; + let mut ordinary_child = child.clone(); + ordinary_child.feature.memory.workspace_settings = None; + bind_child_memory_settings(&ordinary_parent, &mut ordinary_child).unwrap(); + assert!(!ordinary_child.feature.subjektiv.execution_enabled()); child.feature.memory.profile.enabled = false; assert!(bind_child_memory_settings(&parent, &mut child).is_err()); diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index ad49fd8fb..827a33978 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1037,34 +1037,15 @@ permission = "write" #[tokio::test] async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() { let workspace = tempfile::tempdir().unwrap(); - let mut resolved = ProfileResolver::new() + let resolved = ProfileResolver::new() .with_workspace_base(workspace.path()) .resolve( &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"), ProfileResolveOptions::with_worker_name("orchestrator-worker"), ) .unwrap(); - let settings = manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-test".to_string(), - settings_revision: 1, - language: "English".to_string(), - }; - if resolved.manifest.feature.memory.enabled() { - resolved - .manifest - .feature - .memory - .bind_workspace_settings(settings.clone()) - .unwrap(); - } - if resolved.manifest.feature.subjektiv.enabled() { - resolved - .manifest - .feature - .subjektiv - .bind_workspace_settings(settings) - .unwrap(); - } + assert!(resolved.manifest.feature.subjektiv.enabled()); + assert!(!resolved.manifest.feature.subjektiv.execution_enabled()); let workspace_context = WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)); let client = MockClient::new(simple_text_events()); @@ -1090,6 +1071,47 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() { assert!(installed.iter().any(|name| name == "WorkerRemove")); assert!(installed.iter().any(|name| name == "WorkdirDelete")); + assert!( + installed.iter().all(|name| !name.starts_with("Subjektiv")), + "ordinary Orchestrator must not install subject-scoped tools: {installed:?}" + ); +} + +#[tokio::test] +async fn builtin_coder_commits_without_unattached_subjektiv_lifecycle() { + let workspace = tempfile::tempdir().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(workspace.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"), + ProfileResolveOptions::with_worker_name("coder-worker"), + ) + .unwrap(); + assert!(resolved.manifest.feature.subjektiv.enabled()); + assert!(!resolved.manifest.feature.subjektiv.execution_enabled()); + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let (worker, _pwd) = make_worker_with_manifest_and_workspace_context( + client, + resolved.manifest, + WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)), + ) + .await; + let handle = spawn_controller(worker).await; + + handle + .send(Method::submit_text( + protocol::new_submission_request_id(), + "Hello", + )) + .await + .unwrap(); + wait_for_status(&handle, WorkerStatus::Idle).await; + let installed = request_tool_names(&wait_for_captured_request(&client_for_assert).await); + assert!( + installed.iter().all(|name| !name.starts_with("Subjektiv")), + "ordinary Coder must not install subject-scoped tools: {installed:?}" + ); } #[tokio::test] diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d5801d0bd..c4d71e69e 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest { /// Backend-authored immutable Workspace Memory settings snapshot. #[serde(skip, default)] pub resolved_memory_settings: Option, + /// Backend-attested subjektiv singleton attachment. Browser/model input cannot set it. + #[serde(skip, default)] + pub resolved_subjektiv_attached: bool, /// Backend-owned feature enablement; client input cannot set it. #[serde(skip, default)] pub resolved_worker_observation_enabled: bool, @@ -5353,6 +5356,7 @@ fn runtime_create_worker_request( worker_observation_grants: request.resolved_worker_observation_grants.clone(), workspace_api: Some(workspace_api), memory_settings: request.resolved_memory_settings.clone(), + subjektiv_attached: request.resolved_subjektiv_attached, } } @@ -7075,6 +7079,7 @@ mod tests { resolved_control_operation: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), + resolved_subjektiv_attached: false, } } @@ -7363,6 +7368,7 @@ mod tests { resolved_control_operation: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), + resolved_subjektiv_attached: false, }, ) .unwrap(); @@ -7465,6 +7471,7 @@ mod tests { resolved_control_operation: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), + resolved_subjektiv_attached: false, }, ) .unwrap(); @@ -7505,6 +7512,7 @@ mod tests { resolved_control_operation: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), + resolved_subjektiv_attached: false, }, ) .unwrap(); diff --git a/crates/workspace-server/src/runtime_subscription_tests.rs b/crates/workspace-server/src/runtime_subscription_tests.rs index 302c0db3e..b58cd991b 100644 --- a/crates/workspace-server/src/runtime_subscription_tests.rs +++ b/crates/workspace-server/src/runtime_subscription_tests.rs @@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f7db02875..801e10e87 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3410,6 +3410,7 @@ impl WorkspaceApi { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: Some(control_operation), }, ) { @@ -5977,6 +5978,7 @@ fn worker_spawn_request_from_api( resolved_config_bundle: None, resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, @@ -16982,6 +16984,14 @@ async fn scoped_create_subjektiv_subject( )) } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSubjectCursor { + workspace_id: String, + created_at: String, + subject_id: String, +} + async fn scoped_list_subjektiv_subjects( State(api): State, AxumPath(path): AxumPath, @@ -16996,9 +17006,45 @@ async fn scoped_list_subjektiv_subjects( )) .into()); } + let cursor = query + .cursor + .as_deref() + .map(|cursor| decode_subjektiv_cursor::("subjects", cursor)) + .transpose()?; + if cursor + .as_ref() + .is_some_and(|cursor| cursor.workspace_id != path.workspace_id) + { + return Err(Error::InvalidInput( + "subjektiv subject cursor does not match Workspace".to_string(), + ) + .into()); + } let page = open_subjektiv_store(&api)? - .list_subjects(limit) + .list_subjects_after( + limit, + cursor + .as_ref() + .map(|cursor| (cursor.created_at.as_str(), cursor.subject_id.as_str())), + ) .map_err(subjektiv_store_error)?; + let next_cursor = if page.has_more { + page.items + .last() + .map(|subject| { + encode_subjektiv_cursor( + "subjects", + &SubjektivSubjectCursor { + workspace_id: path.workspace_id.clone(), + created_at: subject.created_at.clone(), + subject_id: subject.id.clone(), + }, + ) + }) + .transpose()? + } else { + None + }; Ok(Json(server_api::SubjektivSubjectListResponse { limit, items: page @@ -17006,6 +17052,7 @@ async fn scoped_list_subjektiv_subjects( .into_iter() .map(|subject| subjektiv_subject_response(&api, subject)) .collect(), + next_cursor, has_more: page.has_more, })) } @@ -17179,7 +17226,7 @@ async fn scoped_start_subjektiv_subject_worker( .into()); } worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?); - create_workspace_worker(State(api), headers, Json(worker)).await + create_workspace_worker_inner(api, headers, worker, None, true).await } async fn scoped_stage_subjektiv_candidate( @@ -20190,6 +20237,7 @@ fn start_subjektiv_staging_consolidation( resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: true, resolved_control_operation: None, }, )?; @@ -20359,6 +20407,7 @@ fn start_memory_staging_consolidation( resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, )?; @@ -21480,9 +21529,14 @@ async fn spawn_known_worker( operation_id: scoped_worker_control_operation_id(&controller, &operation_id), input_fingerprint, }); - let response = - create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation) - .await?; + let response = create_workspace_worker_inner( + api.clone(), + headers, + request, + resolved_control_operation, + false, + ) + .await?; if let Err(error) = api .store .create_worker_control_grant(&WorkerControlGrantRecord { @@ -21890,6 +21944,7 @@ async fn scoped_start_workspace_orchestrator( resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, )?; @@ -27040,7 +27095,9 @@ fn validate_caller_worker_singleton_key(singleton_key: Option<&str>) -> Result<( return Ok(()); }; crate::store::validate_worker_singleton_key(singleton_key)?; - if crate::hosts::is_reserved_internal_worker_singleton_key(singleton_key) { + if crate::hosts::is_reserved_internal_worker_singleton_key(singleton_key) + || singleton_key.starts_with(SUBJEKTIV_SINGLETON_PREFIX) + { return Err(Error::InvalidInput(format!( "Worker singleton key {singleton_key:?} is reserved for a Backend-managed Worker" ))); @@ -27053,7 +27110,7 @@ async fn create_workspace_worker( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - create_workspace_worker_inner(api, headers, request, None).await + create_workspace_worker_inner(api, headers, request, None, false).await } async fn create_workspace_worker_inner( @@ -27061,6 +27118,7 @@ async fn create_workspace_worker_inner( headers: HeaderMap, request: CreateWorkspaceWorkerRequest, resolved_control_operation: Option, + resolved_subjektiv_attached: bool, ) -> ApiResult> { let CreateWorkspaceWorkerRequest { runtime_id, @@ -27072,7 +27130,21 @@ async fn create_workspace_worker_inner( workdir_attachments, control_operation_id: _, } = request; - validate_caller_worker_singleton_key(singleton_key.as_deref())?; + if resolved_subjektiv_attached { + let singleton_key = singleton_key.as_deref().ok_or_else(|| { + Error::InvalidInput( + "subject-attached Worker launch requires a Backend-owned singleton key".to_string(), + ) + })?; + if !singleton_key.starts_with(SUBJEKTIV_SINGLETON_PREFIX) { + return Err(Error::InvalidInput( + "subject-attached Worker launch requires a subject singleton key".to_string(), + ) + .into()); + } + } else { + validate_caller_worker_singleton_key(singleton_key.as_deref())?; + } let config_state = api .config_store .load_workspace_config(&api.config.workspace_id)? @@ -27167,6 +27239,7 @@ async fn create_workspace_worker_inner( resolved_control_operation, resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached, }; validate_ticket_assignment_spawn(&api, &runtime_id, &request)?; let assignment = request.ticket_assignment.clone(); @@ -35967,6 +36040,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }; assert!( @@ -36223,6 +36297,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }; @@ -37607,7 +37682,7 @@ mod tests { let request = || CreateWorkspaceWorkerRequest { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), display_name: "Subject Worker".to_string(), - singleton_key: Some("subjektiv:subject-42".to_string()), + singleton_key: Some("custom:subject-42".to_string()), profile: Some("builtin:companion".to_string()), ticket_assignment: None, initial_submit: Vec::new(), @@ -37621,7 +37696,7 @@ mod tests { .unwrap(); assert_eq!( created.worker.singleton_key.as_deref(), - Some("subjektiv:subject-42") + Some("custom:subject-42") ); let Json(duplicate) = create_workspace_worker(State(api.clone()), HeaderMap::new(), Json(request())) @@ -37676,7 +37751,7 @@ mod tests { assert_eq!(restored.state, server_api::WorkerRestoreState::Rejected); assert_eq!( api.store - .current_worker_singleton_owner(&api.config.workspace_id, "subjektiv:subject-42",) + .current_worker_singleton_owner(&api.config.workspace_id, "custom:subject-42",) .unwrap() .unwrap() .worker, @@ -38081,6 +38156,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -38441,6 +38517,126 @@ mod tests { assert_eq!(repeated.staging_resolutions_deleted, 0); } + #[tokio::test] + async fn subjektiv_browser_routes_enforce_router_auth_scope_and_owner_reset() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let non_owner_token = seed_test_api_token(api.store.as_ref(), "subjektiv-non-owner"); + let store = open_subjektiv_store(&api).unwrap(); + let subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let memory = store + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Router contract", + "Browser routes cross the generated Axum boundary.", + "Proves route wiring", + "fixture", + ), + ) + .unwrap(); + api.store + .upsert_memory_document(&crate::store::MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Legacy reset fixture\n".to_string(), + created_at: "2026-10-02T00:00:00Z".to_string(), + updated_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + let app = build_router(api.clone()); + let subject_root = format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}", + subject.id + ); + let memory_root = format!("{subject_root}/memories/{}", memory.id); + + request_json( + app.clone(), + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"), + None, + StatusCode::UNAUTHORIZED, + ) + .await; + let subjects = request_json_authenticated( + app.clone(), + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"), + None, + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(subjects["items"][0]["id"], subject.id); + for uri in [ + subject_root.clone(), + format!("{subject_root}/surface"), + format!("{subject_root}/memories?limit=1"), + memory_root.clone(), + format!("{memory_root}/revisions?limit=1"), + ] { + request_json_authenticated( + app.clone(), + "GET", + &uri, + None, + &owner_token, + StatusCode::OK, + ) + .await; + } + request_json_authenticated( + app.clone(), + "GET", + "/api/w/another-workspace/subjektiv/subjects?limit=1", + None, + &owner_token, + StatusCode::NOT_FOUND, + ) + .await; + request_json_authenticated( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"), + Some(serde_json::json!({ + "confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + })), + &non_owner_token, + StatusCode::FORBIDDEN, + ) + .await; + assert!( + api.store + .get_memory_document(TEST_WORKSPACE_ID) + .unwrap() + .is_some() + ); + let reset = request_json_authenticated( + app, + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"), + Some(serde_json::json!({ + "confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(reset["documents_deleted"], 1); + assert_eq!( + store + .scoped_memory(&subject.id, &memory.id) + .unwrap() + .revision, + 1 + ); + } + #[tokio::test] async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { let workspace = tempfile::tempdir().unwrap(); @@ -38459,7 +38655,10 @@ mod tests { AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), }), - Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }), + Query(server_api::SubjektivSubjectListQuery { + limit: Some(0), + cursor: None, + }), ) .await .unwrap_err(); @@ -38473,12 +38672,31 @@ mod tests { AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), }), - Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }), + Query(server_api::SubjektivSubjectListQuery { + limit: Some(1), + cursor: None, + }), ) .await .unwrap(); assert_eq!(subjects.items.len(), 1); assert!(subjects.has_more); + let Json(next_subjects) = scoped_list_subjektiv_subjects( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Query(server_api::SubjektivSubjectListQuery { + limit: Some(1), + cursor: subjects.next_cursor.clone(), + }), + ) + .await + .unwrap(); + assert_eq!(next_subjects.items.len(), 1); + assert_ne!(next_subjects.items[0].id, subjects.items[0].id); + assert!(!next_subjects.has_more); + assert!(next_subjects.next_cursor.is_none()); let missing = scoped_get_subjektiv_subject( State(api.clone()), @@ -38744,6 +38962,32 @@ mod tests { created.worker.singleton_key.as_deref(), Some(format!("subjektiv:{}", subject.id).as_str()) ); + let worker_ref = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id); + let accepted = api + .runtime + .send_input( + &worker_ref, + WorkerInputRequest { + kind: WorkerInputKind::User, + content: "Commit one subject-scoped Session".to_string(), + submission_request_id: None, + segments: None, + }, + ) + .unwrap(); + assert_eq!(accepted.state, InternalWorkerOperationState::Accepted); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let detail = api.runtime.worker(&worker_ref).unwrap(); + if detail.state == "idle" { + break; + } + assert!( + std::time::Instant::now() < deadline, + "subject-attached Worker did not complete its committed turn" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } let context = server_api::ServerRequestContext { actor: None, worker_source: None, @@ -39795,6 +40039,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: Some(WorkerControlOperation { operation_id: operation_id.to_string(), input_fingerprint: format!("sha256:{operation_id}"), @@ -40074,6 +40319,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -41294,6 +41540,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -42199,6 +42446,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -44442,6 +44690,7 @@ mod tests { EMBEDDED_WORKER_RUNTIME_ID, )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -44746,6 +44995,7 @@ mod tests { EMBEDDED_WORKER_RUNTIME_ID, )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -45489,6 +45739,7 @@ mod tests { EMBEDDED_WORKER_RUNTIME_ID, )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -45591,6 +45842,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }; let source_worker = api @@ -46209,6 +46461,7 @@ mod tests { EMBEDDED_WORKER_RUNTIME_ID, )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -46276,6 +46529,7 @@ mod tests { EMBEDDED_WORKER_RUNTIME_ID, )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -47666,6 +47920,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -47725,6 +47980,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -47838,6 +48094,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -49776,6 +50033,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -50880,6 +51138,7 @@ mod tests { worker_observation_grants: Vec::new(), workspace_api: None, memory_settings: Some(memory_settings), + subjektiv_attached: false, } } @@ -52070,6 +52329,7 @@ mod tests { control_operation_id: None, }, None, + false, ) .await .unwrap(); @@ -52855,6 +53115,7 @@ mod tests { "embedded-worker-runtime", )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -53508,6 +53769,7 @@ mod tests { resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }; let spawned = api @@ -54370,6 +54632,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); resolved_worker_observation_grants: Vec::new(), resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, resolved_control_operation: None, }; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 4d963b05e..2470b81f8 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -18872,6 +18872,7 @@ mod tests { resolved_control_operation: None, resolved_workspace_api: None, resolved_memory_settings: None, + resolved_subjektiv_attached: false, }; let stable_fingerprint = crate::hosts::worker_spawn_create_fingerprint(&request(Some("src"))).unwrap(); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 467ddc862..1c66bc387 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -1091,9 +1091,21 @@ impl SubjektivStore { }) } - /// Lists one deterministic bounded page of subjects. Ordering uses immutable - /// creation time with the host-issued subject id as a total-order tie breaker. + /// Lists the first deterministic bounded page of subjects. Ordering uses + /// immutable creation time with the host-issued subject id as a total-order + /// tie breaker. pub fn list_subjects(&self, limit: usize) -> Result { + self.list_subjects_after(limit, None) + } + + /// Lists a deterministic bounded keyset page after an exact `(created_at, + /// subject_id)` cursor. The cursor fields are wrapped by the public API in + /// an opaque Workspace-bound token. + pub fn list_subjects_after( + &self, + limit: usize, + after: Option<(&str, &str)>, + ) -> Result { if !(1..=100).contains(&limit) { return Err(SubjektivError::InvalidRecord( "subject list limit must be between 1 and 100".to_string(), @@ -1101,16 +1113,32 @@ impl SubjektivStore { } let query_limit = limit.saturating_add(1); self.database.try_with_connection(|connection| { - let mut statement = connection.prepare( + let sql = if after.is_some() { + "SELECT record_json FROM subjects + WHERE created_at > ?1 OR (created_at = ?1 AND subject_id > ?2) + ORDER BY created_at ASC, subject_id ASC + LIMIT ?3" + } else { "SELECT record_json FROM subjects ORDER BY created_at ASC, subject_id ASC - LIMIT ?1", - )?; - let rows = statement - .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; + LIMIT ?1" + }; + let mut statement = connection.prepare(sql)?; let mut items = Vec::new(); - for row in rows { - items.push(parse_subject(&row?)?); + if let Some((created_at, subject_id)) = after { + let rows = statement.query_map( + params![created_at, subject_id, to_i64(query_limit as u64)?], + |row| row.get::<_, String>(0), + )?; + for row in rows { + items.push(parse_subject(&row?)?); + } + } else { + let rows = statement + .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; + for row in rows { + items.push(parse_subject(&row?)?); + } } let has_more = items.len() > limit; if has_more { @@ -3559,6 +3587,12 @@ mod tests { let page = store.list_subjects(2).unwrap(); assert_eq!(page.items, expected[..2]); assert!(page.has_more); + let after = page.items.last().unwrap(); + let next = store + .list_subjects_after(2, Some((&after.created_at, &after.id))) + .unwrap(); + assert_eq!(next.items, expected[2..]); + assert!(!next.has_more); assert!(store.list_subjects(0).is_err()); assert!(store.list_subjects(101).is_err()); diff --git a/docs/development/subjektiv-product-cutover.md b/docs/development/subjektiv-product-cutover.md index d2640f6cd..640f4a740 100644 --- a/docs/development/subjektiv-product-cutover.md +++ b/docs/development/subjektiv-product-cutover.md @@ -19,7 +19,7 @@ Every subject operation is bounded first by the authenticated Workspace and then The current Worker remains Yoi's keyed-singleton responsibility. The subjektiv database records immutable historical Worker and Session attribution, but it contains no second `current_worker_id` mapping and historical attribution is never used to choose or authorize the current Worker. Stopping or replacing a Worker does not retire its subject, and retiring a subject does not stop a Worker. -Workspace Memory settings remain Workspace-owned configuration. When `feature.subjektiv` is enabled, the Backend binds the trusted settings snapshot used for extraction language; the snapshot is not subject identity and cannot be authored by a Profile, Browser, or model. +Workspace Memory settings remain Workspace-owned configuration. `feature.subjektiv` in a Profile is reusable policy, not subject authority: it remains inert for ordinary Ticket/Orchestrator Workers and Internal SubWorkers. Only a Backend-attested subject Worker (`subjektiv:`) or dedicated subject consolidator receives the trusted settings snapshot that activates subjektiv tools, resident load, and extraction. The snapshot is not subject identity and cannot be authored by a Profile, Browser, or model; Reviewer children never inherit the parent's attachment or run independent extraction. ## Session, Memory, and correction semantics diff --git a/openapi/server-api.json b/openapi/server-api.json index 37f741ddf..d1b74ff09 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -9325,6 +9325,12 @@ "SubjektivSubjectListQuery": { "additionalProperties": false, "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, "limit": { "format": "uint", "maximum": 100, @@ -9354,6 +9360,12 @@ "maximum": 100, "minimum": 1, "type": "integer" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -14738,7 +14750,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3" + "x-yoi-source-digest": "sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098" }, "openapi": "3.1.0", "paths": { diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index d97aaf8cd..349033558 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -21,6 +21,10 @@ const staleSubjectId = "stale-subject"; const failedSubjectId = "failed-subject"; const ungeneratedSubjectId = "ungenerated-subject"; const errorSubjectId = "error-subject"; +const pagedSubjectId = "paged-subject"; +const subjectPageCursor = "fixture-subject-page-2"; +const memoryPageCursor = "fixture-memory-page-2"; +const revisionPageCursor = "fixture-revision-page-2"; const paragraphs = Array.from( { length: sectionCount }, @@ -64,6 +68,7 @@ const subjects = [ subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0), subject(errorSubjectId, "Unavailable subject", "retired", 3), ]; +const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { return { @@ -235,37 +240,40 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { }; } -function revisions(memoryId: string) { +function revisions(memoryId: string, cursor: string | null) { + const all = [ + { + revision: 3, + kind: "decision", + state: "active", + claim: "Keep subject Memory provenance typed and visible", + change_reason: "Clarified the durable read contract.", + updated_at: "2026-01-03T03:04:05Z", + }, + { + revision: 2, + kind: "decision", + state: "resolved", + claim: "Historical provenance decision, revision 2", + change_reason: "Resolved after endpoint integration.", + updated_at: "2026-01-02T03:04:05Z", + }, + { + revision: 1, + kind: "decision", + state: "retracted", + claim: "Historical provenance decision, revision 1", + change_reason: "Retracted the legacy shape.", + updated_at: "2026-01-01T03:04:05Z", + }, + ]; + const continued = cursor === revisionPageCursor; return { memory_id: memoryId, current_revision: 3, - items: [ - { - revision: 3, - kind: "decision", - state: "active", - claim: "Keep subject Memory provenance typed and visible", - change_reason: "Clarified the durable read contract.", - updated_at: "2026-01-03T03:04:05Z", - }, - { - revision: 2, - kind: "decision", - state: "resolved", - claim: "Historical provenance decision, revision 2", - change_reason: "Resolved after endpoint integration.", - updated_at: "2026-01-02T03:04:05Z", - }, - { - revision: 1, - kind: "decision", - state: "retracted", - claim: "Historical provenance decision, revision 1", - change_reason: "Retracted the legacy shape.", - updated_at: "2026-01-01T03:04:05Z", - }, - ], - has_more: false, + items: continued ? all.slice(2) : all.slice(0, 2), + ...(continued ? {} : { next_cursor: revisionPageCursor }), + has_more: !continued, }; } @@ -353,7 +361,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { - return json({ limit: 100, items: subjects, has_more: false }); + const continued = url.searchParams.get("cursor") === subjectPageCursor; + return json({ + limit: 100, + items: continued ? [pagedSubject] : subjects, + ...(continued ? {} : { next_cursor: subjectPageCursor }), + has_more: !continued, + }); } if (url.pathname.startsWith(`${subjectsPath}/`)) { const suffix = url.pathname.slice(subjectsPath.length + 1); @@ -366,11 +380,22 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (parts.length === 1) return json(foundSubject); if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); if (parts.length === 2 && parts[1] === "memories") { - return json({ items: memoriesFor(subjectId), has_more: false }); + const paginated = subjectId === representativeSubjectId; + const continued = paginated && url.searchParams.get("cursor") === memoryPageCursor; + const items = continued + ? [memorySummary("memory-page-two", 1, "lesson", "active", "Memory on the next page")] + : memoriesFor(subjectId); + return json({ + items, + ...(paginated && !continued ? { next_cursor: memoryPageCursor } : {}), + has_more: paginated && !continued, + }); } if (parts.length >= 3 && parts[1] === "memories") { const memoryId = parts[2]; - if (parts.length === 4 && parts[3] === "revisions") return json(revisions(memoryId)); + if (parts.length === 4 && parts[3] === "revisions") { + return json(revisions(memoryId, url.searchParams.get("cursor"))); + } if (parts.length === 3) { const requested = url.searchParams.get("revision"); return json(memoryDetail(memoryId, requested === null ? null : Number(requested))); diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 873e7d0a7..978d39a02 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -312,7 +312,20 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown, assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0); + await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( + "link", + { name: "Next page →" }, + ).click(); + await detailPage.getByText("Revision 1", { exact: true }).waitFor(); + assert(new URL(detailPage.url()).searchParams.has("revision_cursor")); + assertEquals( + await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( + "link", + { name: "First page" }, + ).count(), + 1, + ); assertEquals(await detailPage.locator('a[href^="javascript:"]').count(), 0); assertEquals( await detailPage.getByRole("article", { name: "Committed Memory body" }).locator( @@ -329,6 +342,38 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown, await assertMainOwnsVerticalScroll(detailPage); await detailContext.close(); + const paginationContext = await browser.newContext({ viewport: { width: 768, height: 900 } }); + const paginationPage = await paginationContext.newPage(); + await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`); + await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor(); + assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); + await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( + "link", + { name: "Next page →" }, + ).click(); + await paginationPage.getByText("Subject on the next page").waitFor(); + assert(new URL(paginationPage.url()).searchParams.has("cursor")); + assertEquals( + await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( + "link", + { name: "First page" }, + ).count(), + 1, + ); + + await paginationPage.goto( + `${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}`, + ); + await paginationPage.getByRole("heading", { name: "Current Memories", level: 2 }).waitFor(); + await paginationPage.getByRole("navigation", { name: "Current Memory pages" }).getByRole( + "link", + { name: "Next page →" }, + ).click(); + await paginationPage.getByText("Memory on the next page").waitFor(); + assert(new URL(paginationPage.url()).searchParams.has("cursor")); + await assertNoPageWideOverflow(paginationPage); + await paginationContext.close(); + const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); const stateCases = [ { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index a254e53a6..e791c828a 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index 574fe4891..0f0420cc4 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -37,7 +37,7 @@ export type SubjektivSubjectState = "active" | "retired"; export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; -export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; +export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; export type SubjektivMemoryState = "active" | "resolved" | "retracted"; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 2ea35b98e..ee61147a4 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 5d6d6539e..905b323d2 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 6438d4f38..fbac54057 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index db5aedb99..97b8c9c7c 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index bf78b1ad7..ff2df6358 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) +// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index a8b2f8425..8c58076ea 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -228,7 +228,9 @@ function parseStagingEvidence(value: unknown): MemoryStagingEvidence { summary: nullableString(record, "summary"), }; if ("origin" in record) { - result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin); + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); } return result; } @@ -259,7 +261,9 @@ function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef { summary: nullableString(record, "summary"), }; if ("origin" in record) { - result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin); + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); } return result; } @@ -350,10 +354,12 @@ export function parseSubjektivSubjectResponse( updated_at: requiredString(record, "updated_at"), }; if ("current_worker" in record) { - result.current_worker = record.current_worker === null ? null : parseWorkerLaunchWorkerSummary( - record.current_worker, - "Subject current worker", - ); + result.current_worker = record.current_worker === null + ? null + : parseWorkerLaunchWorkerSummary( + record.current_worker, + "Subject current worker", + ); } return result; } @@ -363,8 +369,9 @@ export function parseSubjektivSubjectListResponse( ): SubjektivSubjectListResponse { const record = strictRecord( value, - ["limit", "items", "has_more"], + ["limit", "items", "next_cursor", "has_more"], "Subject list response", + ["next_cursor"], ); const limit = positiveInteger(record.limit, "limit"); if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound"); @@ -373,11 +380,16 @@ export function parseSubjektivSubjectListResponse( ); if (items.length > limit) invalid("items exceeds the declared Subject limit"); assertUnique(items.map((item) => item.id), "Subject ids"); - return { + const hasMore = requiredBoolean(record, "has_more"); + const nextCursor = optionalNullableString(record, "next_cursor"); + assertCursorInvariant(hasMore, nextCursor, "Subject list"); + const result: SubjektivSubjectListResponse = { limit, items, - has_more: requiredBoolean(record, "has_more"), + has_more: hasMore, }; + if (nextCursor !== undefined) result.next_cursor = nextCursor; + return result; } export function parseSubjektivResidentSurfaceResponse( @@ -562,7 +574,9 @@ export function parseSubjektivMemoryReadResponse( ).map(parseMemoryEvidenceCandidate); if ( sourceCandidateIds.length !== sourceCandidates.length || - sourceCandidateIds.some((id, index) => sourceCandidates[index]?.candidate_id !== id) + sourceCandidateIds.some((id, index) => + sourceCandidates[index]?.candidate_id !== id + ) ) { invalid("source candidate ids do not match source candidates"); } @@ -690,7 +704,9 @@ function parseMemoryEvidence(value: unknown): SubjektivMemoryEvidence { : parseEntryRange(record.entry_range, "entry_range"); } if ("origin" in record) { - result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin); + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); } if ("excerpt" in record) result.excerpt = nullableString(record, "excerpt"); if ("summary" in record) result.summary = nullableString(record, "summary"); @@ -735,7 +751,9 @@ function parseMemorySourceEvidenceRef( : parseEntryRange(record.entry_range, "entry_range"); } if ("origin" in record) { - result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin); + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); } return result; } diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 9688fec0f..12c14cedb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -9,6 +9,11 @@ function subjectHref(subjectId: string): string { return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`); } + + function pageHref(cursor?: string | null): string { + const path = workspaceRoute(data.workspaceId, '/memory'); + return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path; + } @@ -33,6 +38,7 @@
No Memory subjects.

Subjects will appear here after they are created for this Workspace.

+ {#if data.cursor}

Return to the first page

{/if}
{:else}
@@ -51,9 +57,12 @@ {/each}
- {#if data.subjects.data.has_more} -

Only the first {data.subjects.data.limit} subjects are shown.

- {/if} + {/if} {:else if data.subjects.error}