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/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/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 0cacb0273..cda30d257 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,97 @@ 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), + cursor: None, + }, + ) + .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 +1011,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/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/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..cd126532d 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,85 @@ 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 + } + + /// 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, + ) -> 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> { + // `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"); + } + 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 +1048,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. @@ -1638,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/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 25fc043b8..3ba5c3e1b 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 @@ -1252,6 +1257,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}; @@ -1276,6 +1339,7 @@ mod tests { "coder", "reviewer", "memory-consolidation", + "subjektiv-memory-consolidation", ] { let role = resolve(name); assert_eq!( @@ -1308,7 +1372,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 @@ -1322,14 +1386,63 @@ mod tests { ); } - let consolidation = resolve("memory-consolidation"); + 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] + 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.subjektiv.profile.enabled); + assert!(!feature.subjektiv.profile.extraction.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!( - consolidation.manifest.model.ref_.as_deref(), - Some("codex-oauth/gpt-5.6-luna") - ); - assert_eq!( - consolidation.manifest.engine.reasoning, - Some(ReasoningControl::Effort(ReasoningEffort::Medium)) + manifest.web.as_ref().and_then(|web| web.enabled), + Some(false) ); } @@ -1477,7 +1590,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/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/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7bfe7213c..2d8e8c6e9 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")] @@ -1241,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, }, @@ -1667,11 +1681,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 +1728,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 +2410,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,16 +2433,63 @@ 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"); } + #[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"}}}}}"#; 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 +2507,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/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..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); @@ -230,7 +234,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 +253,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], }, @@ -258,21 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec { )); let pending = SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event { - subscription_id, - subject_revision: 2, + 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/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 757ab0ee0..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)] @@ -692,6 +696,183 @@ 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scan_cursor: Option, + 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 compact_truncated: bool, + pub lineage: SessionPublicLineage, + /// Opaque Runtime continuation immediately before this result. + pub scan_cursor: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchPage { + pub session_id: String, + pub generation: String, + pub items: Vec, + #[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, + 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 +947,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 +1305,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 +1461,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 7bed30902..d6c7d6535 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -228,12 +228,32 @@ impl_openapi_schema!( FlowSourceResolveRequest, ResolvedFlowSource, MemoryDocumentResponse, + LegacyMemoryResetRequest, + LegacyMemoryResetResponse, MemoryStagingQuery, MemoryStagingListResponse, MemoryBackendRequest, MemoryBackendResponse, MemoryConsolidateStagingRequest, MemoryConsolidationResponse, + SubjektivSubjectCreateRequest, + SubjektivSubjectListQuery, + SubjektivSubjectListResponse, + SubjektivSubjectResponse, + SubjektivResidentSurfaceResponse, + SubjektivResidentSurfaceSnapshot, + SubjektivMemoryDetailQuery, + SubjektivMemoryRevisionsQuery, + SubjektivMemoryListQuery, + SubjektivMemoryQueryRequest, + SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, + SubjektivMemoryListRevisionsResponse, + SubjektivSubjectWorkerStartRequest, + SubjektivStageCandidateRequest, + SubjektivStageCandidateResponse, + SubjektivRecordSessionRequest, + SubjektivRecordSessionResponse, SkillCatalogResponse, SkillDetailResponse, SkillActivationResponse, @@ -1049,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, @@ -1072,6 +1107,7 @@ pub trait ServerApi { )] async fn memory_backend( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[body] request: MemoryBackendRequest, ) -> Result; @@ -1089,6 +1125,198 @@ 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", + 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, + 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; + #[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, + 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; + #[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; + #[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, + 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", status = 200, @@ -4356,6 +4584,1071 @@ 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, +} + +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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[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, 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 { + 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, +} + +/// 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")] +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, + /// 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, + #[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, +} + +#[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)] +#[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, 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, +} + +/// 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 { + ResidentSummary(memory::backend::MemoryResidentSummaryOperation), + Query(SubjektivMemoryQueryRequest), + Read(SubjektivMemoryReadRequest), + ListRevisions(SubjektivMemoryListRevisionsRequest), + ValidateProposal(SubjektivMemoryValidateProposalRequest), + ReceiptStatus(SubjektivMemoryReceiptStatusRequest), + StageExplicit(SubjektivMemoryStageExplicitRequest), + ListCandidates(SubjektivMemoryCandidateListRequest), + ReadCandidate(SubjektivMemoryCandidateReadRequest), + DecideCandidate(SubjektivMemoryCandidateDecisionRequest), + PrepareSurface(SubjektivSurfacePrepareRequest), + PublishSurface(SubjektivSurfacePublishRequest), + FailSurface(SubjektivSurfaceFailureRequest), +} + +#[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, +} + +/// 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 { + 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)] +#[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, + #[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. 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. + 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, 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)] +#[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 { + ResidentSummary(memory::backend::MemoryResidentSummaryOutput), + Query(SubjektivMemoryQueryResponse), + Read(SubjektivMemoryReadResponse), + ListRevisions(SubjektivMemoryListRevisionsResponse), + ProposalValidated(SubjektivMemoryProposalValidationResponse), + ReceiptStatus(SubjektivMemoryReceiptStatusResponse), + Staged(SubjektivMemoryStageExplicitResponse), + Candidates(SubjektivMemoryCandidateListResponse), + Candidate(SubjektivMemoryCandidate), + CandidateDecided(SubjektivMemoryCandidateDecisionResponse), + SurfacePrepared(SubjektivSurfacePrepareResponse), + SurfacePublished(SubjektivSurfacePublishResponse), + SurfaceFailed(SubjektivSurfaceFailureResponse), +} + 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; @@ -8338,6 +9631,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))] @@ -8515,12 +9845,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, next_cursor?: string | null, 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}")) @@ -10535,6 +11900,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] @@ -11521,6 +12897,11 @@ mod openapi_artifact_tests { "worker_control_stop", "worker_observation_capture", "worker_observation_sessions", + "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/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/lib.rs b/crates/session-store/src/lib.rs index b1b427e37..ac44c0be4 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,16 @@ 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, + 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, 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..d569c09b7 --- /dev/null +++ b/crates/session-store/src/public_index.rs @@ -0,0 +1,2046 @@ +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::worker_session_store::RetainedLogRecord; +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, + #[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)] +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, + /// 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 compact_text_truncated: bool, + 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, + }) +} + +/// 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, + }) +} + +/// 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 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; + } + 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, + 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, + 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, + }; + + let (compact_text, compact_text_truncated) = bounded_compact_text(&compact_source); + projected.push(SessionPublicIndexEntry { + segment_id: segment_id.to_string(), + entry_ref, + kind, + provenance: entry.provenance, + timestamp: entry.timestamp, + tool_part, + tool_name, + compact_text, + compact_text_truncated, + full_text, + }); + } + + 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 { + 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 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(); + 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 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_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 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(); + 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(); + 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 fe3ab0e65..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], @@ -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/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/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index 35218068c..3711524e5 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(), @@ -450,6 +468,39 @@ 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(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()) } @@ -645,7 +696,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 +1205,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..eeff41061 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, @@ -1403,7 +1407,12 @@ 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; + 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 // re-count it. cache_creation stays in (it is full @@ -1414,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); } @@ -1475,7 +1492,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 +2353,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 +2444,14 @@ impl App { ) { self.greeting = Some(greeting.clone()); self.context_window = greeting.context_window; - self.session_context_tokens = 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); } @@ -2441,10 +2468,16 @@ 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; + self.session_context_source = None; } let missing_greeting = greeting.is_none(); self.restore_session(session, greeting); @@ -2584,6 +2617,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 }); @@ -2835,6 +2869,9 @@ 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.session_context_source, None); + assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None); } #[test] @@ -2945,6 +2982,8 @@ mod rewind_refresh_tests { tools: vec![], context_window: 100, context_tokens: 10, + reasoning: None, + context_usage: None, } } @@ -3800,6 +3839,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, @@ -4019,6 +4059,7 @@ mod completion_flow_tests { }, revision, status: WorkerStatus::Idle, + greeting: None, session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), @@ -4029,6 +4070,76 @@ 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 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()); @@ -4307,6 +4418,7 @@ mod completion_flow_tests { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), }, + greeting: None, status: WorkerStatus::Running, error: None, in_flight: Default::default(), @@ -4503,15 +4615,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 { @@ -4526,6 +4644,36 @@ 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] + 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); + assert_eq!( + app.session_context_source, + Some(protocol::ContextTokenSource::Estimated) + ); } #[test] @@ -4539,10 +4687,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()); @@ -4564,7 +4741,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; @@ -4572,7 +4749,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/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index ace33d7e5..b4b95755a 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,50 @@ 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); + }; + let display_name = display_name.trim().to_string(); 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); + }; + let uri = uri.trim().to_string(); 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.trim().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 default_ref = default_ref.trim().to_string(); let operation_key = format!( "tui-workspace-create-{}-{}", std::process::id(), @@ -204,27 +236,43 @@ 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); + } + if value.ends_with('\n') { + value.pop(); + if value.ends_with('\r') { + value.pop(); + } + } + Ok(Some(value)) } #[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 +285,58 @@ 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_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"); + + 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/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/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-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 9c1a4f7fa..77b60defa 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, }, ), @@ -2314,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"); } @@ -3101,6 +3102,7 @@ mod tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } @@ -3994,6 +3996,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 { @@ -4078,6 +4082,7 @@ mod ws_tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } 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 a2b49dcb7..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; @@ -36,5 +38,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/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 d46b38847..077c88910 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"; @@ -151,7 +186,6 @@ impl RuntimeWorkerRestoreResult { #[derive(Clone, Debug)] pub struct RuntimeSubscriptionUpdate { - pub subject_revision: u64, pub payload: SubscriptionEventPayload, } @@ -168,7 +202,6 @@ pub enum RuntimeSubscriptionRecvError { pub struct RuntimeEventSelectorSubscription { subscription_id: u64, selector: EventSubscriptionSelector, - snapshot_revision: u64, snapshot: SubscriptionSnapshot, receiver: mpsc::Receiver, lagged: Arc, @@ -180,10 +213,6 @@ impl RuntimeEventSelectorSubscription { &self.selector } - pub fn snapshot_revision(&self) -> u64 { - self.snapshot_revision - } - pub fn snapshot(&self) -> &SubscriptionSnapshot { &self.snapshot } @@ -1280,7 +1309,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 +1326,6 @@ impl Runtime { Ok(RuntimeEventSelectorSubscription { subscription_id, selector, - snapshot_revision, snapshot, receiver, lagged, @@ -2217,9 +2244,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 +2527,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 +2568,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 +2591,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 +2897,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 +2986,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 +3048,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 +3081,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 +3481,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(()) } @@ -3551,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. @@ -3702,8 +3846,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 +3875,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 +3905,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 +4033,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 +4351,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 +4363,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 +4385,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 +4533,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(()) } @@ -5520,6 +5636,7 @@ mod tests { worker, revision, status, + greeting: None, session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: Vec::new(), @@ -5545,6 +5662,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(), @@ -5883,6 +6002,7 @@ mod tests { settings_revision: 1, language: "English".to_string(), }), + subjektiv_attached: false, } } @@ -6647,7 +6767,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"); }; @@ -6655,11 +6774,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:?}"), @@ -6676,7 +6793,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); @@ -6695,7 +6811,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); @@ -6737,7 +6852,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); @@ -6748,7 +6862,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()); @@ -6760,6 +6873,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(); @@ -8108,6 +8302,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/session_public.rs b/crates/worker-runtime/src/session_public.rs new file mode 100644 index 000000000..60b154ac9 --- /dev/null +++ b/crates/worker-runtime/src/session_public.rs @@ -0,0 +1,622 @@ +use std::path::Path; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use runtime_api::{ + SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind, + SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage, + SessionPublicReadRequest, SessionPublicSearchAvailability, SessionPublicSearchItem, + SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart, + SessionPublicUnavailableReason, WorkerSessionArchiveManifest, +}; +use serde::{Deserialize, Serialize}; +use session_store::{ + SessionPublicIndexEntry, SessionPublicIndexEntryKind, SessionPublicIndexLimits, + SessionPublicIndexLineage, SessionPublicIndexOriginKind, SessionPublicIndexReadError, + SessionPublicIndexScanPosition, SessionPublicIndexToolPart, read_session_public_index_entry, + 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, + 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 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 let (Some(expected), Some(cursor)) = + (request.expected_generation.as_deref(), cursor.as_ref()) + && expected != cursor.generation + { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search cursor generation changed", + ); + } + + let query = request + .query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase); + 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 index.session_id != request.expected_session_id { + return unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + + 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_scan_cursor, + has_more: index.has_more, + scanned_bytes: index.scanned_bytes, + 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, + 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 selected = match read_session_public_index_entry( + session_root, + 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(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 selected.session_id != request.expected_session_id { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + let item = selected.item; + let entry = item.entry; + 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: 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(&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: selected.scanned_bytes, + scanned_segments: selected.scanned_segments, + scanned_entries: selected.scanned_entries, + archive_manifest: archive_manifest.map(api_archive_manifest), + }, + } +} + +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", + ), + SessionPublicIndexReadError::StaleCursor => ( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search cursor is stale", + ), + } +} + +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(), + } +} + +#[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); + + 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, + 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, + .. + } + )); + } + + #[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); + } +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 8b98d0f92..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(), @@ -960,14 +960,33 @@ 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 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 + .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(()) @@ -978,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 @@ -985,17 +1010,21 @@ 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 { + .subjektiv + .validate_execution() + .map_err(str::to_string)?; + 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, @@ -1004,6 +1033,33 @@ fn validate_worker_memory_settings( 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 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(()) } @@ -1241,10 +1297,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) => { @@ -4391,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") @@ -4499,6 +4612,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-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 1a7e5d4da..bb700ae41 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -564,6 +564,30 @@ 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, + ) + } + WorkspaceServerOperation::SubjektivMemory(request) => { + generated_workspace_response( + 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 e07aef804..b0096c7df 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(), @@ -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,6 +314,12 @@ 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(); + publish_context_usage( + shared_state, + working_event_tx, + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await; } @@ -767,6 +784,19 @@ impl WorkerController { // the actual registered set instead of a hand-maintained mirror. worker.engine().tool_server_handle().flush_pending(); + // 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() + .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(); @@ -781,6 +811,16 @@ impl WorkerController { manifest_toml.clone(), greeting, )); + let usage_state = shared_state.clone(); + let usage_events = working_event_tx.clone(); + worker.engine_mut().on_usage(move |event| { + 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)); } @@ -1141,6 +1181,22 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } +fn validate_memory_lifecycle_targets( + memory: &manifest::MemoryFeatureProfileConfig, + subjektiv: &manifest::SubjektivFeatureProfileConfig, +) -> std::io::Result<()> { + 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 lifecycles 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 @@ -1250,28 +1306,95 @@ where ), ); } + let memory_profile = &worker.manifest().feature.memory.profile; + let subjektiv_profile = &worker.manifest().feature.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(memory_config) = memory_lifecycle_config - && let Some(memory_lifecycle) = - crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( + 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 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_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); + } + 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 ordinary_subjektiv_features_enabled + && let Some(subjektiv_lifecycle) = + crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( worker.manifest_lifecycle_features_enabled(), - memory_config, + worker.manifest().feature.subjektiv.clone(), worker.committed_session_capture_handle(), worker.session_extension_handle(), worker.workspace_client_handle(), @@ -1282,7 +1405,7 @@ where worker.working_event_sender(), )? { - feature_registry.add_module(memory_lifecycle); + feature_registry.add_module(subjektiv_lifecycle); } if sub_worker_enabled && !worker_enabled { feature_registry.add_module( @@ -1490,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); @@ -2443,6 +2566,12 @@ async fn controller_loop( } } }; + publish_context_usage( + &shared_state, + &working_event_tx, + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); if !matches!( result, Err(WorkerError::Store(_)) @@ -2510,6 +2639,12 @@ async fn controller_loop( .await { worker.clear_in_flight_events(); + publish_context_usage( + &shared_state, + &working_event_tx, + Some(worker.total_tokens().tokens), + protocol::ContextTokenSource::Estimated, + ); let snapshot = shared_state.transition(WorkerState::Idle); let _ = working_event_tx.send(Event::WorkerState { snapshot }); } @@ -3317,7 +3452,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, @@ -3360,6 +3512,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 @@ -3368,10 +3521,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, + }), } } @@ -3410,6 +3572,123 @@ 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"); + 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_gate); + assert!(materialize_gate < materialize); + assert!(materialize < exposure); + assert!( + startup[materialize_gate..materialize] + .contains("needs_initial_session_head_materialization") + ); + } + + #[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 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 legacy_and_subjektiv_lifecycles_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_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("cannot both be enabled")); + + 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] fn no_controller_parent_notification_uses_durable_pending_authority() { let temp = TempDir::new().unwrap(); @@ -3551,6 +3830,8 @@ mod tests { tools: Vec::new(), context_window: 200_000, context_tokens: 0, + reasoning: None, + context_usage: None, }, )); let notify_buffer = NotifyBuffer::new(); @@ -3600,6 +3881,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(), @@ -4308,6 +4591,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/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 417c9ca80..b1206d1bc 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -11,11 +11,15 @@ 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; 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.rs b/crates/worker/src/feature/builtin/memory.rs index 3198b5ea6..f4199d0d5 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)] @@ -84,8 +84,96 @@ 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 + } + + 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( @@ -125,6 +213,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 +226,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 +298,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 { @@ -355,9 +650,25 @@ 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) -> Option { + async fn load(&self) -> SystemPromptContribution { match self .client .execute_memory_backend_operation( @@ -367,14 +678,45 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { ) .await { - Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => output.content, + Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) => { + resident_summary_contribution(output) + } 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 + } + } + } +} + +#[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 } } } @@ -384,7 +726,6 @@ 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 { @@ -407,16 +748,17 @@ 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 = profile_name == Some("memory-consolidation"); config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; @@ -458,15 +800,162 @@ impl MemoryFeatureInstallPlan { None }; + let module = 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, })) } } +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.execution_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.execution_enabled() || config.profile.extraction.enabled { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv consolidation profile requires trusted 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, @@ -508,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( @@ -517,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]; @@ -531,8 +1109,8 @@ mod tests { let body = serde_json::json!({ "status": "ok", "result": { - "kind": "tool_output", - "summary": "resident Memory summary collected", + "kind": "resident_summary", + "availability": availability, "content": content, } }) @@ -550,6 +1128,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() @@ -631,15 +1242,65 @@ 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()) ); } + #[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(); @@ -671,25 +1332,141 @@ 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()) ); } + 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); @@ -759,4 +1536,63 @@ mod tests { assert!(names.contains(&"MemoryStagingRead".to_string())); 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()); + 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 a6b095c58..3720adb28 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -39,8 +39,45 @@ 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, +} + +#[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 @@ -50,9 +87,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, + config: LifecycleConfig, + target: ExtractionTarget, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, workspace_client: Arc, @@ -63,6 +108,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( @@ -77,10 +131,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 @@ -119,7 +170,8 @@ impl MemoryLifecycleFeature { ) -> Self { Self { task: MemoryLifecycleTask { - config, + config: LifecycleConfig::from_memory(config), + target: ExtractionTarget::WorkspaceMemory, capture, extensions, workspace_client, @@ -133,6 +185,49 @@ 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.execution_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", + )); + } + Ok(Some(Self { + task: MemoryLifecycleTask { + config: LifecycleConfig::from_subjektiv(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,23 +247,60 @@ 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() { + 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 @@ -184,14 +316,18 @@ 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 { + audit + }; let capture = match self.capture.capture() { Ok(capture) => capture, Err(error) => { @@ -223,7 +359,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 @@ -242,7 +378,6 @@ impl MemoryLifecycleTask { }; let Some(threshold) = self .config - .profile .extraction .threshold .filter(|threshold| *threshold > 0) @@ -333,13 +468,22 @@ impl MemoryLifecycleTask { None, ) .await; - let output_state = MemoryStagingOutputState::new( - view.clone(), - Arc::clone(&self.workspace_client), - source, - audit.run_id.to_string(), - ); - let client = if let Some(model) = self.config.profile.extraction.model.as_ref() { + 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.extraction.model.as_ref() { match crate::model_client::build_client(model) { Ok(client) => client, Err(error) => { @@ -378,9 +522,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; }); }; @@ -402,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), @@ -485,6 +629,38 @@ 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) + { + 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, @@ -492,9 +668,23 @@ 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(), extract::EXTRACT_DOMAIN, payload) + .append_if_current(&capture.location(), self.pointer_domain(), payload) .map_err(hook_internal)? { audit @@ -532,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 { @@ -540,8 +730,14 @@ 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.consolidation_request_enabled => { + self.request_consolidation(false).await; + } + ExtractionTarget::Subjektiv => { + self.request_consolidation(true).await; + } + ExtractionTarget::WorkspaceMemory => {} } } extraction @@ -549,20 +745,53 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { } impl MemoryLifecycleTask { - async fn request_consolidation(&self) { + 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, 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 - { + .with_workspace_settings(self.config.workspace_settings.as_ref()); + 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(), @@ -668,13 +897,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 +967,7 @@ struct WorkerAuditBase { trigger: memory::audit::AuditTrigger, memory_settings: Option, model: Option, + persist_legacy_backend: bool, } impl WorkerAuditBase { @@ -746,18 +982,24 @@ impl WorkerAuditBase { trigger, memory_settings: None, model, + persist_legacy_backend: true, } } - 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 without_legacy_backend(mut self) -> Self { + self.persist_legacy_backend = false; + self + } + + 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 } @@ -784,15 +1026,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(), @@ -822,7 +1068,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}; @@ -859,6 +1105,9 @@ 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_subjektiv_consolidation = request.path.ends_with("/subjektiv/consolidation"); let is_stage_candidate = request .body .as_deref() @@ -868,6 +1117,37 @@ 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, + 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, @@ -901,6 +1181,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, @@ -1055,6 +1381,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, @@ -1071,7 +1417,8 @@ permission = "write" Ok(true) }); MemoryLifecycleTask { - config: test_config(), + config: LifecycleConfig::from_memory(test_config()), + target: ExtractionTarget::WorkspaceMemory, capture: capture_handle, extensions, workspace_client, @@ -1122,6 +1469,96 @@ 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 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(|| { + 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}; @@ -1138,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!( @@ -1236,6 +1672,189 @@ 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.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")) + })); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/subjektiv/consolidation")) + .count(), + 1 + ); + } + + #[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.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(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.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); + 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.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()); @@ -1316,6 +1935,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.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()); @@ -1388,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(); @@ -1418,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()); @@ -1455,6 +2116,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 { @@ -1562,7 +2224,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 +2248,7 @@ permission = "write" serde_json::json!({"invalid": true}), ), ]; - assert!(extract_pointer(&capture).is_err()); + assert!(extract_pointer(&capture, extract::EXTRACT_DOMAIN).is_err()); } #[test] @@ -1605,9 +2270,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"); diff --git a/crates/worker/src/feature/builtin/memory_staging_output.rs b/crates/worker/src/feature/builtin/memory_staging_output.rs index 4ccc90afe..e51939a3a 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(|| { @@ -346,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), @@ -357,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), @@ -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/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs new file mode 100644 index 000000000..7e11ac8ee --- /dev/null +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -0,0 +1,977 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +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; +use crate::feature::background::{ + BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger, + FeatureBackgroundTask, +}; +use crate::feature::builtin::memory::{ + WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile, +}; +use crate::feature::builtin::memory_surface_output::{ + MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition, +}; +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; +const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。"; + +#[derive(Clone)] +pub(crate) struct SubjektivSurfaceLifecycleFeature { + task: SubjektivSurfaceLifecycleTask, +} + +#[derive(Clone)] +struct SubjektivSurfaceLifecycleTask { + workspace_client: Arc, + manifest: WorkerManifest, + client: Box, + prompts: Arc>, + 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, + workspace_client: Arc, + manifest: WorkerManifest, + client: Box, + prompts: Arc>, + workspace_context: WorkerWorkspaceContext, + ) -> std::io::Result> { + 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 !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, + "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()) + } +} + +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( + &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(failure) => { + self.record_failure(&generation.generation_id, failure.reason_code) + .await; + tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "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, SurfaceEditorFailure> { + let language = self + .manifest + .feature + .subjektiv + .workspace_settings() + .map(|settings| settings.language) + .ok_or_else(|| { + SurfaceEditorFailure::editor(HookError::new( + HookErrorCategory::Internal, + "surface editor requires bound subjektiv Workspace settings", + )) + })?; + let system_prompt = self + .prompts + .load_full() + .subjektiv_memory_surface_system(&language) + .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(); + 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: Box::new(InputBudgetLlmClient { + inner: self.client.clone_boxed(), + input_token_budget: generation.input_token_budget, + }), + 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 { + 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", + ) + }) + .map_err(SurfaceEditorFailure::editor) + } + + 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 bounded_editor_input( + system_prompt: &str, + generation: &server_api::SubjektivSurfacePrepareResponse, +) -> Result<(String, Vec), HookError> { + 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 { + // 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 { + 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, + body_md, + 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.subjektiv.profile.enabled = true; + manifest + .feature + .subjektiv + .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 bounds one category"); + 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" + ); + } + + #[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)); + 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 new file mode 100644 index 000000000..2527d0cd5 --- /dev/null +++ b/crates/worker/src/feature/builtin/memory_surface_output.rs @@ -0,0 +1,256 @@ +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_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 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(), + }); + (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/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs new file mode 100644 index 000000000..4a958fddc --- /dev/null +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -0,0 +1,1572 @@ +//! 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::HashSet; +use std::sync::Arc; + +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, SubjektivMemoryReceiptStatusRequest, + 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::{ + BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError, + HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, HookPreRequestAction, PreLlmRequest, + PreRequestContext, 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 RETRY_HOOK: &str = "retry-committed-explicit-memory"; +const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite"; +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- 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."; + +#[derive(Clone)] +pub(crate) struct SubjektivMemoryFeature { + state: SubjektivMemoryState, +} + +#[derive(Clone)] +struct SubjektivMemoryState { + client: Arc, + capture: CommittedSessionCaptureHandle, +} + +#[derive(Clone)] +struct PendingExplicit { + receipt_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.execution_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 }, + })) + } +} + +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, + )) + .with_hook(HookDeclaration::new( + RETRY_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + .with_hook(HookDeclaration::new( + REWRITE_HOOK, + FeatureHookPoint::BeforeSessionRewrite, + )) + } + + 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(), + }, + )?; + 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(), + }, + ) + } +} + +#[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 = 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()), + 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()))?; + 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) + } +} + +#[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 = + 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 => { + 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 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()) + { + 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 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, + "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 = match self.state.stage( + &receipt_id, + &capture.session_id, + kind, + claim, + why_useful, + staleness, + entries, + proposal, + ) { + Ok(response) => response, + Err(error) => return structured_conflict_output(error), + }; + 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 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 {}: {detail}", + response.status + ); + 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}")) + }) + } + + 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, + 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> { + 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; + } + }; + 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( + 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 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 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( + "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}; + use std::collections::HashSet; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[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 { + 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 { + 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)] + 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>, + staged: Mutex>, + fail_stages: AtomicUsize, + } + + 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.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, + }) + } + other => panic!("unexpected test operation: {other:?}"), + }; + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&response).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, + has_committed_run: true, + run_exit: CommittedRunExit::Finished, + history: Vec::new(), + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }); + SubjektivMemoryFeature { + state: SubjektivMemoryState { + client: Arc::new(TestWorkspaceClient), + capture, + }, + } + } + + #[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(), 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] + 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()); + } + + #[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"] { + 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!( + 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"); + tool.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0)) + .await + .unwrap(); + + 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 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, + has_committed_run: true, + 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, + has_committed_run: true, + 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: 2, + has_committed_run: true, + 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()), + }, + ), + 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(), + }) + }); + let state = SubjektivMemoryState { + client: client.clone(), + capture, + }; + assert_eq!( + state + .receipt_status("explicit:session-1:call-commit") + .unwrap() + .status, + SubjektivMemoryReceiptStatus::PendingCommit + ); + + PendingRetryHook { + state: state.clone(), + } + .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(); + let requests = client.requests.lock().unwrap(); + 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!( + request.evidence[0].origin.as_ref().unwrap().kind, + memory::schema::EvidenceOriginKind::ModelOutput + ); + assert_eq!( + request.source_refs[0].evidence_id.as_deref(), + Some(request.evidence[0].id.as_str()) + ); + } +} 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..e0fb4b8f1 --- /dev/null +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -0,0 +1,855 @@ +//! 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.execution_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, + .. + } + } + )); + + 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] + 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/internal_worker.rs b/crates/worker/src/internal_worker.rs index 21ff16e03..b431f2bf9 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, @@ -824,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, @@ -851,8 +878,20 @@ 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(); + let usage_events = event_tx.clone(); + worker.engine_mut().on_usage(move |event| { + 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); @@ -874,6 +913,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)] @@ -886,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); @@ -1174,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(); @@ -1206,6 +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: greeting.map(|greeting| Arc::new(RwLock::new(greeting))), child_registry: None, sink, fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1485,11 +1540,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/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 8b3af7dd8..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, @@ -117,11 +131,158 @@ 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 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(); + 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/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index bba265bdd..f8c52cc47 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -94,6 +94,8 @@ pub enum WorkerPrompt { CompactSystem, MemoryExtractSystem, MemoryConsolidationSystem, + SubjektivMemoryConsolidationSystem, + SubjektivMemorySurfaceSystem, FlowVerifierSystem, BackendJobSystem, NotifyWrapper, @@ -102,6 +104,7 @@ pub enum WorkerPrompt { WorkingBoundariesSection, AgentsMdSection, ResidentMemorySummarySection, + ResidentMemoryRestoreSection, WorkerOrchestrationGuidanceSection, SubWorkerSpawnToolDescription, } @@ -112,6 +115,10 @@ 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::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system", Self::FlowVerifierSystem => "internal.flow_verifier_system", Self::BackendJobSystem => "internal.backend_job_system", Self::NotifyWrapper => "internal.notify_wrapper", @@ -120,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" } @@ -131,6 +139,8 @@ impl WorkerPrompt { WorkerPrompt::CompactSystem, WorkerPrompt::MemoryExtractSystem, WorkerPrompt::MemoryConsolidationSystem, + WorkerPrompt::SubjektivMemoryConsolidationSystem, + WorkerPrompt::SubjektivMemorySurfaceSystem, WorkerPrompt::FlowVerifierSystem, WorkerPrompt::BackendJobSystem, WorkerPrompt::NotifyWrapper, @@ -139,6 +149,7 @@ impl WorkerPrompt { WorkerPrompt::WorkingBoundariesSection, WorkerPrompt::AgentsMdSection, WorkerPrompt::ResidentMemorySummarySection, + WorkerPrompt::ResidentMemoryRestoreSection, WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::SubWorkerSpawnToolDescription, ]; @@ -449,6 +460,21 @@ impl PromptCatalog { single("language", language), ) } + pub fn subjektiv_memory_consolidation_system( + &self, + language: &str, + ) -> Result { + self.render( + WorkerPrompt::SubjektivMemoryConsolidationSystem, + 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) } @@ -479,6 +505,19 @@ impl PromptCatalog { single("summary", summary), ) } + pub fn resident_memory_restore_section( + &self, + surface_ready: bool, + summary: Option<&str>, + ) -> Result { + self.render( + WorkerPrompt::ResidentMemoryRestoreSection, + minijinja::context! { + surface_ready => surface_ready, + summary => summary.unwrap_or_default(), + }, + ) + } pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, @@ -913,6 +952,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/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/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/session_capture.rs b/crates/worker/src/session_capture.rs index 335af3852..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, @@ -600,6 +609,76 @@ 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 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 @@ -1028,4 +1107,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/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..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, @@ -995,6 +1048,7 @@ fn internal_worker_snapshot( worker, revision, session: snapshot.session, + greeting: snapshot.greeting, status: snapshot.status, error: snapshot.error, in_flight: snapshot.in_flight, @@ -1121,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 { @@ -1141,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 { @@ -1193,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/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/src/worker.rs b/crates/worker/src/worker.rs index 6b91903ea..1e0441297 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; @@ -624,6 +626,10 @@ pub enum WorkspaceServerOperation { }, WorkerObservationSessions, WorkerObservationCapture(server_api::WorkerObservationSubjectRef), + SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), + SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), + SubjektivMemory(server_api::SubjektivMemoryBackendRequest), + SubjektivSession(server_api::SubjektivSessionBackendRequest), } fn workspace_server_json_request( @@ -702,6 +708,18 @@ 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) + } + 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) + } } } @@ -1414,7 +1432,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 @@ -2322,9 +2341,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)] @@ -2488,6 +2513,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 @@ -2741,6 +2768,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(), @@ -3043,7 +3071,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(); @@ -3463,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() @@ -3489,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, @@ -3807,6 +3843,66 @@ 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 contribution = if self.inject_resident_summary { + source.load().await + } else { + 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(surface_ready, summary)?; + 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 @@ -3814,9 +3910,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 @@ -3849,7 +3948,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 { @@ -3905,6 +4011,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 @@ -4695,6 +4805,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, })?; @@ -5012,6 +5123,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), @@ -5423,6 +5535,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), @@ -5446,6 +5559,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"), @@ -5462,11 +5576,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), @@ -6537,6 +6653,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(), @@ -6626,6 +6743,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(), @@ -6750,6 +6868,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(), @@ -7131,6 +7250,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 @@ -8690,13 +8810,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() } @@ -8708,7 +8828,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, @@ -11063,6 +11186,191 @@ 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 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, + 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(); @@ -11430,6 +11738,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), @@ -11460,6 +11769,7 @@ mod build_summary_prompt_tests { worker .commit_entry(LogEntry::RunCancelled { ts: segment_log::now_millis(), + entry_id: None, }) .unwrap(); assert_eq!( @@ -11470,6 +11780,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, }) diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 75d6e8ee4..827a33978 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1037,25 +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(); - 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(), - }) - .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()); @@ -1081,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/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/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/hosts.rs b/crates/workspace-server/src/hosts.rs index c89fc2909..14b4381ec 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, @@ -1217,6 +1220,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 +2096,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 +3017,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 +3752,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 +5093,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, @@ -5246,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, } } @@ -5370,6 +5481,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 +5489,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 { @@ -7030,6 +7142,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, } } @@ -7318,6 +7431,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(); @@ -7420,6 +7534,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(); @@ -7460,6 +7575,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/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index f5f021c53..3a24911fc 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 ( @@ -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 + 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, 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 ( @@ -973,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, 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); +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, @@ -998,9 +993,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 ); @@ -1217,11 +1212,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); @@ -1232,7 +1227,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/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/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/retention.rs b/crates/workspace-server/src/retention.rs index 9847d8b5d..bb36636f8 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,78 @@ 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 + AND revoked_at IS NULL + )", + 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( @@ -1170,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')", [])?; @@ -1220,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/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..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, } } @@ -264,16 +265,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 +424,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 6a715f6cc..801e10e87 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, @@ -1500,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<()> { @@ -1630,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)), } } @@ -1763,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)); } @@ -1946,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, @@ -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(), @@ -3404,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), }, ) { @@ -3448,17 +3455,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)? @@ -5425,6 +5434,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(), )) @@ -5432,6 +5444,48 @@ 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_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(), + )) + .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::subjektiv_memory_backend( + service.clone(), + )) + .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())) @@ -5924,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, @@ -6952,6 +7007,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, @@ -6969,6 +7042,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 @@ -6976,6 +7050,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 @@ -7006,6 +7081,286 @@ 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_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, + 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_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, + 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 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 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, + 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, @@ -10217,6 +10572,19 @@ struct ScopedWorkspacePath { workspace_id: String, } +#[derive(Debug, Clone, Deserialize)] +struct ScopedSubjektivSubjectPath { + workspace_id: String, + 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, @@ -16322,6 +16690,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, @@ -16339,11 +16736,12 @@ 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 = 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()), @@ -16352,9 +16750,3589 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } +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"); + ( + 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 => ( + memory::backend::MemoryResidentSummaryAvailability::Stale, + None, + ), + crate::subjektiv::SurfaceAvailability::Failed => ( + memory::backend::MemoryResidentSummaryAvailability::Failed, + None, + ), + }; + memory::backend::MemoryResidentSummaryOutput { + availability, + content, + } +} + +const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:"; +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) + .map_err(|error| ApiError::from(Error::Store(error.to_string()))) +} + +#[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 { + 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 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, + context: &server_api::ServerRequestContext, +) -> 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(), + ) + })?; + let worker_id = source.worker_id.as_deref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "subjektiv operations require 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, 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)) +} + +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( + api: &WorkspaceApi, + workspace_id: &str, + context: &server_api::ServerRequestContext, + session_id: &str, +) -> ApiResult { + 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()); + } + 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)), + )) +} + +#[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, + 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 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_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 + .items + .into_iter() + .map(|subject| subjektiv_subject_response(&api, subject)) + .collect(), + next_cursor, + has_more: page.has_more, + })) +} + +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::SubjektivSubjectNotFound(path.subject_id.clone()))?; + 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, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .retire_subject(&path.subject_id) + .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))) +} + +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::SubjektivSubjectNotFound(path.subject_id.clone()))?; + 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_inner(api, headers, worker, None, true).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 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, + runtime_scan_cursor: String, + attribution_has_more: bool, +} + +#[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 && 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 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 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 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 + }; + 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, + 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", + ); + } + } + } +} + +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 { + if has_more || state.current.is_some() { + state.partial = true; + } + break; + } + 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::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), + 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, + 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), + scan_cursor: runtime_scan_cursor, + 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 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, + 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(), + runtime_scan_cursor: item_scan_cursor, + attribution_has_more, + }); + break; + } + items.push(projected); + consumed += 1; + } + if state.current.is_some() { + break; + } + if page.has_more { + 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, + session_id: attribution.session_id, + runtime_id: attribution.runtime_id, + worker_id: attribution.worker_id, + storage: source.storage, + archive_id: source.archive_id, + generation, + runtime_scan_cursor: next_scan_cursor, + attribution_has_more, + }); + break; + } + if state.session_id.is_some() { + has_more = false; + break; + } + if items.len() >= limit { + break; + } + } + + 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 { + 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_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 { + 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 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) +} + +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; +const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; +const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024; +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; + +#[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, + /// 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( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + 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, + &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) => { + 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) + .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) => { + 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. + let attribution = subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &input.session_id, + )?; + server_api::SubjektivMemoryBackendResponse::Staged(subjektiv_memory_stage_explicit( + &store, + input, + 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), + ) + } + 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 { + 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, + 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( + "stale_cursor: 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 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(), + ) + .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()); + } + 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, 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, + ) + } else { + (0, 0, 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 mut source_candidate_ids = Vec::new(); + let mut source_candidates = Vec::new(); + let mut derived_from = Vec::new(); + 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(&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( + "evidence", + &SubjektivEvidenceCursor { + subject_id: subject_id.to_string(), + memory_id: record.id.clone(), + revision: record.revision, + offset: next_evidence_offset, + evidence_offset: next_nested_evidence_offset, + source_ref_offset: next_nested_source_ref_offset, + }, + ) + }) + .transpose()?; + + let mut response = 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_byte_offset, + body_next_offset, + body_next_byte_offset, + body_truncated, + source_candidate_ids, + source_candidates, + 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( + 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 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 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!( + 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 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, + 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() + } else { + body.split_inclusive('\n').collect() + } +} + +fn bound_subjektiv_nested_evidence( + candidates: &[server_api::SubjektivMemoryEvidenceCandidate], +) -> ApiResult<()> { + 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()) + } +} + +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 { + let value = value + .chars() + .map(|character| { + if character.is_control() || matches!(character, '"' | '\\') { + ' ' + } else { + character + } + }) + .collect::(); + 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(); + 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 + || !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); + 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 { .. } + | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { + Error::RepositoryConflict(format!("revision_conflict: {error}")) + } + crate::subjektiv::SubjektivError::CandidateResolved(_) + | crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => { + Error::RepositoryConflict(format!("candidate_decision_conflict: {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()), + } +} + const MEMORY_CONSOLIDATION_PROFILE: &str = "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, + )?)) +} + +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 store = open_subjektiv_store(&api)?; + let (candidate_count, total_bytes) = store + .pending_staging_backlog(subject_id) + .map_err(subjektiv_store_error)?; + 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!( + "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. 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, + &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_subjektiv_attached: true, + 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); + 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(), + 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, @@ -16429,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, }, )?; @@ -17323,13 +21302,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), })?; @@ -17337,14 +21312,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( @@ -17557,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 { @@ -17937,10 +21914,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"; } @@ -17966,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, }, )?; @@ -20270,22 +24249,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, @@ -23115,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" ))); @@ -23128,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( @@ -23136,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, @@ -23147,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)? @@ -23242,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(); @@ -23385,7 +27383,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" @@ -23717,17 +27715,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)? @@ -25143,11 +29144,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", @@ -25182,7 +29179,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 @@ -25980,9 +29977,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()) @@ -25998,7 +29998,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, @@ -27610,12 +31609,34 @@ impl From for ApiError { severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(&ticket_error.to_string()), }], + 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_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 { fn with_diagnostics(error: Error, diagnostics: Vec) -> Self { Self { error, diagnostics } @@ -27645,7 +31666,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 } @@ -27670,6 +31698,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" => { @@ -27841,6 +31870,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| { @@ -27913,7 +31986,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, @@ -27924,10 +31996,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, @@ -30286,6 +34354,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( @@ -31504,7 +35610,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, @@ -31935,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!( @@ -32191,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, }; @@ -33575,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(), @@ -33589,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())) @@ -33644,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, @@ -33652,6 +37759,2260 @@ 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, + .. + } + }) + )); + } + + #[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 + )); + } + + #[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_subjektiv_attached: false, + 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( + 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 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_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(); + 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), + cursor: None, + }), + ) + .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), + 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()), + 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(); + 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()) + .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 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, + 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(), + }; + 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(), + 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.clone(), + 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); + + 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, + 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 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.clone(), + 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 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() + .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 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, + &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 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, + byte_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, + byte_offset: initial_read.body_next_byte_offset, + limit: Some(1), + evidence_cursor: None, + }, + ) + .unwrap_err(); + assert!(matches!(unbound_continuation.error, Error::InvalidInput(_))); + + let read = subjektiv_memory_read( + &store, + &subject.id, + server_api::SubjektivMemoryReadRequest { + memory_id: first.id.clone(), + revision: Some(1), + offset: Some(1), + byte_offset: None, + 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, 2); + + 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)), + 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 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", + 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 escape_heavy_body = "\\\u{0000}\u{0001}".repeat(8_000); + 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", + escape_heavy_body.clone(), + "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.clone(), + revision: Some(1), + offset: None, + byte_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_EVIDENCE_ANCHOR_PAGE_SIZE); + assert_eq!(nested.evidence_total, nested_count); + 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.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_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, + &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] async fn singleton_retry_disconnect_replacement_and_restore_are_generation_fenced() { let workspace = tempfile::tempdir().unwrap(); @@ -33678,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}"), @@ -33957,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, }, ) @@ -34931,10 +41294,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 { @@ -35175,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, }, ) @@ -36080,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, }, ) @@ -38323,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, }, ) @@ -38627,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, }, ) @@ -39370,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, }, ) @@ -39472,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 @@ -40090,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, }, ) @@ -40157,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, }, ) @@ -40474,31 +46847,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( @@ -40522,6 +46906,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()); } @@ -41530,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, }, ) @@ -41589,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, }, ) @@ -41627,7 +48019,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( @@ -41660,7 +48053,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| { @@ -41672,7 +48065,6 @@ mod tests { }) .await .expect("WorkerRemove catalog removal broadcast"); - assert!(removal.revision > 0); } #[tokio::test] @@ -41702,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, }, ) @@ -41709,7 +48102,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, @@ -41725,7 +48119,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| { @@ -41737,7 +48131,6 @@ mod tests { }) .await .expect("spawn compensation catalog removal broadcast"); - assert!(removal.revision > 0); sync_worker_observation(&api, &summary).unwrap(); assert!( api.store @@ -43579,7 +49972,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) @@ -43639,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, }, ) @@ -43703,7 +50098,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 @@ -43747,20 +50143,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) @@ -43776,11 +50170,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() @@ -43802,8 +50195,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(); @@ -43811,7 +50204,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 @@ -44455,10 +50847,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(); @@ -44747,6 +51138,7 @@ mod tests { worker_observation_grants: Vec::new(), workspace_api: None, memory_settings: Some(memory_settings), + subjektiv_attached: false, } } @@ -45937,6 +52329,7 @@ mod tests { control_operation_id: None, }, None, + false, ) .await .unwrap(); @@ -46722,6 +53115,7 @@ mod tests { "embedded-worker-runtime", )), resolved_memory_settings: Some(test_worker_memory_settings()), + resolved_subjektiv_attached: false, resolved_control_operation: None, }, ) @@ -47243,10 +53637,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; @@ -47255,7 +53646,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, @@ -47266,7 +53656,7 @@ mod tests { ) = frame.payload && worker_id.as_str() == expected_worker_id { - return subject_revision; + return; } } } @@ -47379,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 @@ -47386,7 +53777,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(); @@ -48218,6 +54609,148 @@ 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_subjektiv_attached: false, + 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!( + subjektiv_scope_from_singleton_key("subjektiv:subject-42") + .unwrap() + .0, + "subject-42" + ); + 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" + ); + } + fn write_ticket( database_path: &Path, workspace_id: &str, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 88c87cd6c..2470b81f8 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 = 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"; @@ -68,6 +68,12 @@ 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 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#" @@ -230,6 +236,21 @@ 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, + }, + Migration { + version: 75, + 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)] @@ -638,17 +659,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 +680,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, } @@ -1026,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, @@ -1091,6 +1107,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( @@ -1431,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>; @@ -1623,7 +1653,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 +1663,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 +1670,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 +1677,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( @@ -2355,11 +2380,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( @@ -2573,8 +2600,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()? @@ -2606,13 +2633,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()?; @@ -2627,15 +2658,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); }; @@ -2648,8 +2679,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()? @@ -2660,13 +2691,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()?; @@ -3891,6 +3926,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 @@ -3909,6 +3949,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( @@ -3942,6 +3987,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)?; @@ -5795,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( @@ -6383,7 +6517,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } let commit = WorkerRegistryProjectionCommit { - revision: current_worker_projection_revision(&tx, workspace_id)?, changes: Vec::new(), }; tx.commit()?; @@ -6404,10 +6537,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 +6907,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; if removal_blocks_upsert { return Ok(WorkerRegistryProjectionCommit { - revision: current_worker_projection_revision(&tx, &record.workspace_id)?, changes: Vec::new(), }); } @@ -6785,8 +6916,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 @@ -6821,11 +6951,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 { @@ -6833,15 +6964,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 +7059,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 +7092,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 +7125,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 +7146,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 +7161,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 +7170,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 +7185,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 +7208,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 +7230,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 +7269,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 +7307,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(), } }; @@ -7274,6 +7345,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"#, @@ -7418,14 +7490,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) }) } @@ -9108,7 +9188,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"#, @@ -9252,7 +9332,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"#, @@ -10530,81 +10610,67 @@ 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()?; 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 +10696,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 +10743,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 +10766,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 +10781,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 +10833,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 +13883,366 @@ 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 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; + + 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, + 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 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, + 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 + ); + 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"))?; @@ -14477,6 +14809,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, @@ -14742,6 +15107,160 @@ 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'); + 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; + 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); + + 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<()> { conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -14750,6 +15269,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) @@ -15665,6 +16186,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 +16705,18 @@ mod tests { version: 73, name: WORKER_SINGLETON_OWNERSHIP_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 74, + name: ORDERED_WORKER_PROJECTION_MIGRATION_NAME.to_string(), + }, + WorkspaceSchemaMigrationStep { + version: 75, + name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), + }, + WorkspaceSchemaMigrationStep { + version: 76, + name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(), + }, ] ); @@ -16259,6 +16794,12 @@ 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(), + ), + (76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16545,7 +17086,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, 75, 76 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -16554,7 +17095,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(), 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()); @@ -18067,20 +18608,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( @@ -18095,10 +18632,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(), @@ -18130,17 +18666,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 ); @@ -18232,11 +18767,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 ); @@ -18341,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(); @@ -19647,6 +20179,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(); @@ -19702,11 +20399,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") @@ -19925,6 +20618,131 @@ 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) + ); + 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) + .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(); @@ -19956,7 +20774,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") @@ -19975,7 +20793,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 +20802,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 +20825,6 @@ INSERT INTO worker_registry ( .reconcile_worker_registry_snapshot( "local-dev", "embedded", - 1, - 10, &[observed.clone(), orphan], "2", ) @@ -20003,15 +20833,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 +20856,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 +20876,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 +20907,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 +20960,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 +20976,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 +20987,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 +20997,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/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 6c95c3188..1c66bc387 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -22,6 +22,16 @@ 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; @@ -41,6 +51,18 @@ 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}`" + )] + 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}")] @@ -49,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, @@ -117,8 +141,120 @@ pub struct SubjectRecord { pub updated_at: String, } -/// The existing extraction schema with only host-owned subject scope and -/// persistence time added. Model input still contains neither field. +#[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. +#[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) + } +} + +#[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 { + 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 { @@ -136,6 +272,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, } @@ -159,9 +297,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)] @@ -208,7 +361,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, @@ -270,7 +424,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, @@ -283,7 +437,51 @@ pub struct SurfaceSnapshot { pub created_at: String, } -#[derive(Debug, Clone)] +#[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 { Create, Revise { @@ -292,6 +490,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#" @@ -602,11 +845,145 @@ 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(()) +} + +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(()) +} + +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, + 'dirty', + NULL, + 'legacy_snapshot_requires_regeneration', + 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( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "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 = FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS); @@ -714,6 +1091,63 @@ impl SubjektivStore { }) } + /// 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(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + 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 mut statement = connection.prepare(sql)?; + let mut items = Vec::new(); + 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 { + 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 { @@ -737,36 +1171,196 @@ 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)?; - 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)?; + validate_staging_admission(&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)) + }) + } + + /// 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, + ) -> 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) + }) + } + + /// 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 }) }) } @@ -789,6 +1383,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 { @@ -845,7 +1489,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() { @@ -857,52 +1501,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) }) } @@ -985,6 +1704,314 @@ 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) + }) + } + + /// 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) = + ready_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, @@ -994,16 +2021,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 { @@ -1012,38 +2030,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, + ) }) } @@ -1066,6 +2077,354 @@ 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 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_index, bucket) in buckets.iter().enumerate() { + if let Some(material) = bucket.get(offset) { + ordered.push((bucket_index, 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, + 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 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, + 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, + 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, @@ -1225,6 +2584,100 @@ 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) +} + +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())); + } + validate_staged_revision_proposal(transaction, &record)?; + 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) } @@ -1340,8 +2793,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 @@ -1358,7 +2829,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], @@ -1436,6 +2914,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 +2970,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 { @@ -1507,9 +3039,179 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> }); } } + if let Some(proposal) = &record.revision_proposal { + validate_revision_proposal_metadata(proposal)?; + } 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 { + 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_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, +) -> 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)?; @@ -1552,6 +3254,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 +3282,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)?; @@ -1727,6 +3446,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::{ @@ -1736,6 +3456,25 @@ mod tests { use super::*; use crate::FeatureStorage; + static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( + 1, + "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() } @@ -1787,6 +3526,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, @@ -1797,6 +3549,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, @@ -1808,6 +3569,161 @@ 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); + 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()); + + 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"); @@ -1819,8 +3735,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() @@ -1851,12 +3811,383 @@ 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 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(); + 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 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(); + 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!( @@ -1885,6 +4216,645 @@ 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 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(); + 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(); @@ -2338,6 +5308,322 @@ 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 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(); + 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/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_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index 3dee38110..d9fce280e 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,42 @@ mod tests { ); } + #[test] + fn create_rejects_noncanonical_repository_keys_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 owner_account_id = owner_account(store.as_ref()); + + 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, + }, + }; + + 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] async fn create_recovers_same_reserved_identity_after_material_write_failure() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index fb5d1a6a0..a19f095a4 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -73,12 +73,11 @@ 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_archive_observe_grants", "worker_session_archives", "worker_singleton_owners", "worker_tombstones", 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/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/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 5d59a1e7c..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 @@ -19,14 +24,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 @@ -82,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 @@ -120,3 +129,191 @@ 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. + +## 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 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) 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. + +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 +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. + + +## 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 +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. + +## 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 new file mode 100644 index 000000000..76f957018 --- /dev/null +++ b/docs/design/subjektiv-surface.md @@ -0,0 +1,75 @@ +# 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 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 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. + +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 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 + +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 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..640f4a740 --- /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. `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 + +- **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 d7745ca2b..d1b74ff09 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": { @@ -2832,6 +2879,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 +3144,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": { @@ -8694,6 +8781,665 @@ ], "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": { + "role": { + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "SubjektivSubjectListQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "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" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "limit", + "items", + "has_more" + ], + "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 +14750,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a" + "x-yoi-source-digest": "sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098" }, "openapi": "3.1.0", "paths": { @@ -19023,6 +19769,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", @@ -27945,6 +28788,997 @@ ] } }, + "/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": [ + { + "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}/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", + "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}/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", + "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/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 c35c50ed4..ae8f3aeec 100644 --- a/resources/profiles/base.dcdl +++ b/resources/profiles/base.dcdl @@ -26,16 +26,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 new file mode 100644 index 000000000..b3c9ec16d --- /dev/null +++ b/resources/profiles/subjektiv-memory-consolidation.dcdl @@ -0,0 +1,37 @@ +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"; }; + web = { enabled = false; }; + + feature = { + task = { enabled = false; }; + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = true; extraction = { enabled = false; }; }; + web = { enabled = false; }; + image = { enabled = false; }; + sub_worker = { enabled = false; }; + flow = { enabled = false; }; + worker = { enabled = false; direct_spawn = false; }; + workspace_worker_discovery = { enabled = false; }; + objective = { enabled = 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; }; + }; +} diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index 7d61dcce9..d38cfda1a 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -23,6 +23,8 @@ 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"; +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"; @@ -31,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 @@ -60,6 +63,8 @@ in backend_job_system = internalBackendJobSystem.content; 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; @@ -68,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..fc8a22907 --- /dev/null +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -0,0 +1,17 @@ + +--- +## 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 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 %} 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..7cb150dc7 --- /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 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. + +## 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/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. 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..349033558 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,271 @@ 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 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 }, (_, 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), +]; +const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); + +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, 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: continued ? all.slice(2) : all.slice(0, 2), + ...(continued ? {} : { next_cursor: revisionPageCursor }), + has_more: !continued, + }; +} const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { @@ -110,15 +358,51 @@ 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`) { + + const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; + if (url.pathname === subjectsPath) { + const continued = url.searchParams.get("cursor") === subjectPageCursor; 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", + 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); + 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") { + 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, url.searchParams.get("cursor"))); + } + 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" @@ -129,7 +413,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 +421,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/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 54c8d403f..978d39a02 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,117 @@ 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(), 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( + "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 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 = [ + { + 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/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index 0186652ae..c80c4f697 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -148,7 +148,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", @@ -156,7 +156,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/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 4a43a0719..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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// 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 c01a71858..0f0420cc4 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, next_cursor?: string | null, 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/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index e36d8205b..d364f42df 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"},"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":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":[]}}}}}}}},{"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 8bae7b59a..b5f4ab628 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, /** @@ -304,11 +325,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 +353,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 }; @@ -364,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/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ed25bd296..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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// 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 090217650..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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// 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 014210126..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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// 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 7085c833c..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:a0956070b29f49aafac3b19a0fa54626f285bdf01337f26afc4389ff6d23495a) +// 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/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 9ba9ba9f7..c31a50c44 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/ConsoleLineItem.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleLineItem.browser.spec.ts new file mode 100644 index 000000000..3a89175d9 --- /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.ts"; +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/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts new file mode 100644 index 000000000..f45776f62 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -0,0 +1,53 @@ +// @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%)"); + + 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/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 9804b5f80..a404dcc26 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)} diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts index 741eb118c..53b120231 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts @@ -1,20 +1,32 @@ -import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; -import { parseMemoryDocumentResponse } from "#lib/workspace/memory/api.ts"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivSubjectListResponse, +} from "#lib/workspace/memory/api.ts"; import type { PageLoad } from "./$types"; -export const load: PageLoad = async ({ fetch, params }) => { +const MAX_CURSOR_BYTES = 16_384; + +function boundedCursor(value: string | null): string | null { + if (value === null || value.length === 0) return null; + return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES + ? value + : null; +} + +export const load: PageLoad = async ({ fetch, params, url }) => { + const cursor = boundedCursor(url.searchParams.get("cursor")); + const query = new URLSearchParams({ limit: "100" }); + if (cursor) query.set("cursor", cursor); return { workspaceId: params.workspaceId, - memory: await loadJson( + cursor, + subjects: await loadJson( fetch, - workspaceApiPath(params.workspaceId, "/memory"), + `${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?${query}`, 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..2bfe0b358 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte @@ -0,0 +1,489 @@ + + + + {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.

+ {#if data.cursor}

Return to the first page

{/if} +
+ {:else} + + + {/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..a48096163 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.ts @@ -0,0 +1,59 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivMemoryQueryResponse, + parseSubjektivResidentSurfaceResponse, + parseSubjektivSubjectResponse, +} from "#lib/workspace/memory/api.ts"; +import type { PageLoad } from "./$types"; + +const MAX_CURSOR_BYTES = 16_384; + +function boundedCursor(value: string | null): string | null { + if (value === null || value.length === 0) return null; + return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES + ? value + : null; +} + +export const load: PageLoad = async ({ fetch, params, url }) => { + const subjectId = params.subjectId; + const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; + const cursor = boundedCursor(url.searchParams.get("cursor")); + const memoryQuery = new URLSearchParams({ limit: "100" }); + if (cursor) memoryQuery.set("cursor", cursor); + 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`) + }?${memoryQuery}`, + undefined, + parseSubjektivMemoryQueryResponse, + MEMORY_API_LOAD_POLICY, + ), + ]); + + return { + workspaceId: params.workspaceId, + subjectId, + cursor, + 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..07a1108fe --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -0,0 +1,650 @@ + + + + {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.

+ {#if data.revisionCursor}

Return to the first page

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

    {revision.claim}

    + {revision.change_reason} · +
    +
  2. + {/each} +
+ + {/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..79feec58a --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts @@ -0,0 +1,95 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivMemoryListRevisionsResponse, + parseSubjektivMemoryReadResponse, + parseSubjektivSubjectResponse, +} from "#lib/workspace/memory/api.ts"; +import type { PageLoad } from "./$types"; + +const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/; +const MAX_CURSOR_BYTES = 16_384; + +function boundedCursor( + value: string | null, + maxBytes = MAX_CURSOR_BYTES, +): string | null { + if (value === null || value.length === 0) return null; + return new TextEncoder().encode(value).byteLength <= maxBytes ? value : null; +} + +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 = boundedCursor( + url.searchParams.get("evidence_cursor"), + 512, + ); + if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor); + const revisionCursor = boundedCursor(url.searchParams.get("revision_cursor")); + const revisionQuery = new URLSearchParams({ limit: "100" }); + if (revisionCursor) revisionQuery.set("cursor", revisionCursor); + + 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`) + }?${revisionQuery}`, + undefined, + (value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + ]); + + return { + workspaceId: params.workspaceId, + subjectId, + memoryId, + revisionCursor, + 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..d163fa182 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,272 @@ 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", + cursor: "subject-current", + subjects: result({ + limit: 100, + items: [subject()], + next_cursor: "subject-next", + has_more: true, + }), + }, + } 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.getByRole("link", { name: "First page" }).getAttribute("href")) + .toBe("/w/workspace-1/memory"); + expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) + .toContain("cursor=subject-next"); + 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", + cursor: "memory-current", + 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", + }, + ], + next_cursor: "memory-next", + has_more: true, + }), + }, + } 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(); + expect(screen.getByRole("navigation", { name: "Current Memory pages" })) + .not.toBeNull(); + expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) + .toContain("cursor=memory-next"); }); -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", + revisionCursor: "revision-current", + 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", + }], + next_cursor: "revision-next", + has_more: true, + }), + }, + } 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(); + expect(screen.getByRole("navigation", { name: "Revision history pages" })) + .not.toBeNull(); + expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) + .toContain("revision_cursor=revision-next"); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 23dd14fc8..97dbd8dbf 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -21,6 +21,7 @@ } from "#lib/workspace/console/composer-command.ts"; import { FileCompletions } from "#lib/workspace/console/file-completions.ts"; import WorkerRunStatus from "#lib/workspace/console/WorkerRunStatus.svelte"; + import WorkerContextStatus from "#lib/workspace/console/WorkerContextStatus.svelte"; import { resolveWorkerControlShortcut } from "#lib/workspace/console/worker-control-shortcuts.ts"; import { consoleWorkerViews, @@ -1651,6 +1652,7 @@
+
diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 8d01e387b..8d58c8561 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,404 @@ 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); + const nextPage = { + limit: 1, + items: [subject()], + next_cursor: "subjektiv.subjects.next", + has_more: true, + }; + assertEquals(parseSubjektivSubjectListResponse(nextPage), nextPage); + assertThrows( + () => + parseSubjektivSubjectListResponse({ + limit: 1, + items: [subject()], + has_more: true, + }), + "cursor does not match has_more", + ); + 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 +483,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", ); }); 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("