diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 0cacb0273..1032cda9c 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -3,8 +3,11 @@ use server_api::{ CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, - RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, - WorkspaceRuntimeResource, + RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery, + SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse, + SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, + WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use ticket::{ MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, @@ -410,6 +413,94 @@ impl BackendWorkspaceProductClient { crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit) } + pub fn list_subjektiv_subjects( + &self, + limit: usize, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + self.generated(move |client| async move { + client + .subjektiv_subject_list( + workspace_id, + SubjektivSubjectListQuery { limit: Some(limit) }, + ) + .await + }) + } + + pub fn subjektiv_subject( + &self, + subject_id: &str, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + self.generated(move |client| async move { + client.subjektiv_subject_get(workspace_id, subject_id).await + }) + } + + pub fn subjektiv_resident_surface( + &self, + subject_id: &str, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + self.generated(move |client| async move { + client + .subjektiv_resident_surface(workspace_id, subject_id) + .await + }) + } + + pub fn list_subjektiv_memories( + &self, + subject_id: &str, + query: &SubjektivMemoryListQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_list(workspace_id, subject_id, query) + .await + }) + } + + pub fn subjektiv_memory( + &self, + subject_id: &str, + memory_id: &str, + query: &SubjektivMemoryDetailQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let memory_id = memory_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_detail(workspace_id, subject_id, memory_id, query) + .await + }) + } + + pub fn list_subjektiv_memory_revisions( + &self, + subject_id: &str, + memory_id: &str, + query: &SubjektivMemoryRevisionsQuery, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let subject_id = subject_id.to_string(); + let memory_id = memory_id.to_string(); + let query = query.clone(); + self.generated(move |client| async move { + client + .subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query) + .await + }) + } + pub fn launch_ticket_intake( &self, ticket_id: &str, @@ -917,6 +1008,45 @@ mod tests { handle.join().unwrap(); } + #[test] + fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() { + let (base_url, request, handle) = + one_response_server("200 OK", r#"{"items":[],"has_more":false}"#); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); + + let response = client + .list_subjektiv_memories( + "subject/a", + &SubjektivMemoryListQuery { + query: Some("stable cursor".to_string()), + kinds: None, + states: Some(vec![ + server_api::SubjektivMemoryState::Active, + server_api::SubjektivMemoryState::Resolved, + ]), + limit: Some(25), + cursor: None, + }, + ) + .unwrap(); + + assert!(response.items.is_empty()); + let request = request.recv().unwrap(); + assert!( + request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?") + ); + assert!(request.contains("query=stable+cursor")); + assert!(request.contains("states=active%2Cresolved")); + assert!(request.contains("limit=25")); + assert!(request.contains("authorization: Bearer test-backend-token\r\n")); + handle.join().unwrap(); + } + #[test] fn objective_list_uses_workspace_scoped_backend_route() { let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 78d91b1ff..3bbcb7c86 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1241,6 +1241,64 @@ mod tests { } } + #[test] + fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() { + let tmp = TempDir::new().unwrap(); + let resolve = |name: &str| { + ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, name), + ProfileResolveOptions::with_worker_name(format!("{name}-worker")), + ) + .unwrap() + .manifest + }; + + for name in ["coder", "companion", "intake", "reviewer", "orchestrator"] { + let manifest = resolve(name); + assert!( + manifest.feature.subjektiv.profile.enabled, + "{name} must enable subjektiv" + ); + assert!( + manifest.feature.subjektiv.profile.extraction.enabled, + "{name} must enable subject extraction" + ); + assert!( + !manifest.feature.memory.profile.enabled, + "{name} must not enable legacy Workspace Memory" + ); + } + + for name in ["default", "standalone", "backend-job"] { + let manifest = resolve(name); + assert!(!manifest.feature.memory.profile.enabled, "{name}"); + assert!(!manifest.feature.subjektiv.profile.enabled, "{name}"); + } + + let legacy_consolidator = resolve("memory-consolidation"); + assert!(legacy_consolidator.feature.memory.profile.enabled); + assert!(legacy_consolidator.feature.memory.profile.staging_tools); + assert!( + legacy_consolidator + .feature + .memory + .profile + .extraction + .enabled + ); + assert!( + legacy_consolidator + .feature + .memory + .profile + .consolidation + .request_enabled + ); + assert!(!legacy_consolidator.feature.subjektiv.profile.enabled); + } + #[test] fn builtin_profiles_pin_role_models_and_reasoning() { use crate::model::{ReasoningControl, ReasoningEffort}; @@ -1298,7 +1356,7 @@ mod tests { role.manifest.engine.reasoning, Some(ReasoningControl::Effort(ReasoningEffort::High)) ); - let extraction = &role.manifest.feature.memory.profile.extraction; + let extraction = &role.manifest.feature.subjektiv.profile.extraction; assert_eq!( extraction .model @@ -1341,12 +1399,10 @@ mod tests { let manifest = resolved.manifest; let feature = &manifest.feature; - assert!(feature.memory.profile.enabled); - assert!(feature.memory.profile.staging_tools); - assert!(!feature.memory.profile.resident.inject_summary); - assert!(!feature.memory.profile.extraction.enabled); - assert!(!feature.memory.profile.consolidation.request_enabled); - assert!(!feature.subjektiv.profile.enabled); + assert!(!feature.memory.profile.enabled); + assert!(!feature.memory.profile.staging_tools); + assert!(feature.subjektiv.profile.enabled); + assert!(!feature.subjektiv.profile.extraction.enabled); assert!(!feature.task.enabled); assert!(!feature.web.enabled); assert!(!feature.image.enabled); @@ -1518,7 +1574,8 @@ mod tests { panic!("unexpected error: {error}"); }; assert_eq!(target, ProfileExecutionTarget::Standalone); - assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv)); + assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket)); assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref())); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 73c6d0741..f580ce4d6 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -228,6 +228,8 @@ impl_openapi_schema!( FlowSourceResolveRequest, ResolvedFlowSource, MemoryDocumentResponse, + LegacyMemoryResetRequest, + LegacyMemoryResetResponse, MemoryStagingQuery, MemoryStagingListResponse, MemoryBackendRequest, @@ -235,7 +237,18 @@ impl_openapi_schema!( MemoryConsolidateStagingRequest, MemoryConsolidationResponse, SubjektivSubjectCreateRequest, + SubjektivSubjectListQuery, + SubjektivSubjectListResponse, SubjektivSubjectResponse, + SubjektivResidentSurfaceResponse, + SubjektivResidentSurfaceSnapshot, + SubjektivMemoryDetailQuery, + SubjektivMemoryRevisionsQuery, + SubjektivMemoryListQuery, + SubjektivMemoryQueryRequest, + SubjektivMemoryQueryResponse, + SubjektivMemoryReadResponse, + SubjektivMemoryListRevisionsResponse, SubjektivSubjectWorkerStartRequest, SubjektivStageCandidateRequest, SubjektivStageCandidateResponse, @@ -1056,6 +1069,21 @@ pub trait ServerApi { #[path] workspace_id: String, ) -> Result; + #[post( + "/api/w/{workspace_id}/memory/reset", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn legacy_memory_reset( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: LegacyMemoryResetRequest, + ) -> Result; + #[get( "/api/w/{workspace_id}/memory/staging", status = 200, @@ -1110,6 +1138,19 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivSubjectCreateRequest, ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_list( + &self, + #[path] workspace_id: String, + #[query] query: SubjektivSubjectListQuery, + ) -> Result; #[get( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}", status = 200, @@ -1123,6 +1164,63 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_resident_surface( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_list( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[query] query: SubjektivMemoryListQuery, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_detail( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[path] memory_id: String, + #[query] query: SubjektivMemoryDetailQuery, + ) -> Result; + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_memory_revisions( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[path] memory_id: String, + #[query] query: SubjektivMemoryRevisionsQuery, + ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire", status = 200, @@ -4499,6 +4597,25 @@ pub struct SubjektivSubjectCreateRequest { pub role: String, } +pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectListResponse { + #[schemars(range(min = 1, max = 100))] + pub limit: usize, + pub items: Vec, + pub has_more: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectResponse { @@ -4515,6 +4632,150 @@ pub struct SubjektivSubjectResponse { pub current_worker: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivResidentSurfaceAvailability { + Ungenerated, + Stale, + Failed, + Ready, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentSurfaceSnapshot { + pub snapshot_id: String, + pub body_md: String, + #[serde(default)] + pub memory_refs: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub built_from_store_revision: u64, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentSurfaceResponse { + pub subject_id: String, + pub availability: SubjektivResidentSurfaceAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +fn serialize_optional_query_enum_list( + value: &Option>, + serializer: S, +) -> Result +where + T: Serialize, + S: serde::Serializer, +{ + let Some(items) = value else { + return serializer.serialize_none(); + }; + let mut encoded = Vec::with_capacity(items.len()); + for item in items { + match serde_json::to_value(item).map_err(serde::ser::Error::custom)? { + serde_json::Value::String(value) => encoded.push(value), + _ => { + return Err(serde::ser::Error::custom( + "query-list values must serialize as strings", + )); + } + } + } + serializer.serialize_some(&encoded.join(",")) +} + +fn deserialize_optional_query_enum_list<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + T: serde::de::DeserializeOwned, + D: serde::Deserializer<'de>, +{ + let Some(encoded) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + encoded + .split(',') + .map(|value| { + serde_json::from_value(serde_json::Value::String(value.to_string())) + .map_err(serde::de::Error::custom) + }) + .collect::, _>>() + .map(Some) +} + +/// Browser GET query for committed subject Memory. Enum lists use a bounded, +/// comma-separated scalar representation so URL-form encoding stays portable. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_query_enum_list", + deserialize_with = "deserialize_optional_query_enum_list" + )] + pub kinds: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_query_enum_list", + deserialize_with = "deserialize_optional_query_enum_list" + )] + pub states: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl From for SubjektivMemoryQueryRequest { + fn from(value: SubjektivMemoryListQuery) -> Self { + Self { + query: value.query, + kinds: value.kinds, + states: value.states, + limit: value.limit, + cursor: value.cursor, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryDetailQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub byte_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 1000))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_cursor: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivMemoryRevisionsQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjektivSubjectWorkerStartRequest { @@ -5197,6 +5458,7 @@ pub struct SubjektivSurfaceFailureResponse { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { + ResidentSummary(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), @@ -5368,6 +5630,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "result", content = "data", rename_all = "snake_case")] pub enum SubjektivMemoryBackendResponse { + ResidentSummary(memory::backend::MemoryResidentSummaryOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), @@ -9364,6 +9627,43 @@ pub struct MemoryDocumentResponse { pub record_source: String, } +pub const LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES: usize = 256; + +fn deserialize_legacy_memory_reset_confirmation<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + if value.len() > LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES { + return Err(serde::de::Error::custom( + "legacy Memory reset confirmation is too long", + )); + } + Ok(value) +} + +/// Explicit destructive reset request. The confirmation must exactly equal +/// `RESET LEGACY MEMORY ` for the Workspace named in the route. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LegacyMemoryResetRequest { + #[serde(deserialize_with = "deserialize_legacy_memory_reset_confirmation")] + #[schemars(length(max = 256))] + pub confirmation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LegacyMemoryResetResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub documents_deleted: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub staging_records_deleted: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub staging_resolutions_deleted: usize, +} + /// Candidate kinds exposed by the Memory staging resource. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -9541,12 +9841,47 @@ pub fn memory_api_typescript() -> String { MemoryStagingEntry::decl(&config), MemoryStagingListResponse::decl(&config), ]; + // These browser DTOs include nested types owned by `memory` and the Worker + // launch API. Keep their exported names explicit so the generated Web + // contract remains stable without requiring those crates to depend on + // `ts-rs` solely for this read-only projection. + let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; + +export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; + +export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; + +export type SubjektivMemoryState = "active" | "resolved" | "retracted"; + +export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; + +export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; + +export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; + +export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; + +export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; + +export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; + +export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, }; + +export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, }; + +export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; + +export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; + +export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; + +export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, };"#; let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" ); format!( - "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 2bce258c0..b0096c7df 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -784,10 +784,12 @@ impl WorkerController { // the actual registered set instead of a hand-maintained mirror. worker.engine().tool_server_handle().flush_pending(); - // Runtime-owned fresh Workers persist their initial model-visible head - // before controller exposure. Feature installation above must happen first - // so the resident contribution is loaded into that durable prompt. Restored - // Workers retain their established first-turn refresh lifecycle. + // Runtime-owned Workers persist their current model-visible head before + // controller exposure. Feature installation above must happen first so a + // fresh subject Worker captures the current resident surface in its durable + // prompt. Restored Workers defer their one-shot append-only resident refresh + // until the next model-visible context materialization, after the Runtime has + // committed restoration of the existing durable head. if runtime_managed && worker.needs_initial_session_head_materialization() { worker .materialize_durable_session_head() @@ -1179,18 +1181,17 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } -fn validate_automatic_memory_extraction_targets( +fn validate_memory_lifecycle_targets( memory: &manifest::MemoryFeatureProfileConfig, subjektiv: &manifest::SubjektivFeatureProfileConfig, ) -> std::io::Result<()> { - if memory.enabled - && memory.extraction.enabled - && subjektiv.enabled - && subjektiv.extraction.enabled - { + let legacy_lifecycle = + memory.enabled && (memory.extraction.enabled || memory.consolidation.request_enabled); + let subjektiv_lifecycle = subjektiv.enabled && subjektiv.extraction.enabled; + if legacy_lifecycle && subjektiv_lifecycle { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker", + "Workspace Memory and subjektiv lifecycles cannot both be enabled for one Worker", )); } Ok(()) @@ -1307,56 +1308,75 @@ where } let memory_profile = &worker.manifest().feature.memory.profile; let subjektiv_profile = &worker.manifest().feature.subjektiv.profile; - validate_automatic_memory_extraction_targets(memory_profile, subjektiv_profile)?; + validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?; let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare( worker.manifest(), worker.workspace_client_handle(), worker.prompts().load_full(), )?; - let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| { + let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| { ( plan.resident_summary_source.clone(), plan.system_prompt_override.clone(), ) }); - let memory_lifecycle_config = memory_install_plan - .as_ref() - .map(|plan| plan.resolved_config.clone()); + let memory_lifecycle_config = worker.manifest().feature.memory.clone(); if let Some(plan) = memory_install_plan { feature_registry.add_module(plan.module); } - if let Some(subjektiv_memory) = - crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( - &worker.manifest().feature.subjektiv, - worker.committed_session_capture_handle(), + let subjektiv_consolidation_plan = + crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare( + worker.manifest(), + worker.workspace_client_handle(), + worker.prompts().load_full(), + )?; + if let Some(plan) = subjektiv_consolidation_plan { + feature_prompt_contribution = Some((None, Some(plan.system_prompt_override))); + feature_registry.add_module(plan.module); + } + let ordinary_subjektiv_features_enabled = + crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest()); + if let Some(resident_summary_source) = + crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( + worker.manifest(), worker.workspace_client_handle(), )? + { + feature_prompt_contribution = Some((Some(resident_summary_source), None)); + } + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_memory) = + crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? { feature_registry.add_module(subjektiv_memory); } - if let Some(subjektiv_sessions) = - crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( - &worker.manifest().feature.subjektiv, - worker.committed_session_capture_handle(), - worker.workspace_client_handle(), - )? + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_sessions) = + crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? { feature_registry.add_module(subjektiv_sessions); } - if let Some(memory_config) = memory_lifecycle_config - && let Some(memory_lifecycle) = - crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( - worker.manifest_lifecycle_features_enabled(), - memory_config, - worker.committed_session_capture_handle(), - worker.session_extension_handle(), - worker.workspace_client_handle(), - spawner_manifest.clone(), - worker.llm_client_handle(), - prompts.clone(), - spawner_workspace_context.clone(), - worker.working_event_sender(), - )? + if let Some(memory_lifecycle) = + crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( + worker.manifest_lifecycle_features_enabled(), + memory_lifecycle_config, + worker.committed_session_capture_handle(), + worker.session_extension_handle(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + worker.working_event_sender(), + )? { feature_registry.add_module(memory_lifecycle); } @@ -1370,19 +1390,20 @@ where )? { feature_registry.add_module(surface_lifecycle); } - if let Some(subjektiv_lifecycle) = - crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( - worker.manifest_lifecycle_features_enabled(), - worker.manifest().feature.subjektiv.clone(), - worker.committed_session_capture_handle(), - worker.session_extension_handle(), - worker.workspace_client_handle(), - spawner_manifest.clone(), - worker.llm_client_handle(), - prompts.clone(), - spawner_workspace_context.clone(), - worker.working_event_sender(), - )? + if ordinary_subjektiv_features_enabled + && let Some(subjektiv_lifecycle) = + crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config( + worker.manifest_lifecycle_features_enabled(), + worker.manifest().feature.subjektiv.clone(), + worker.committed_session_capture_handle(), + worker.session_extension_handle(), + worker.workspace_client_handle(), + spawner_manifest.clone(), + worker.llm_client_handle(), + prompts.clone(), + spawner_workspace_context.clone(), + worker.working_event_sender(), + )? { feature_registry.add_module(subjektiv_lifecycle); } @@ -1592,7 +1613,7 @@ where ), )); } - if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution { + if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution { worker.install_system_prompt_contribution(resident_summary, system_prompt_override); } worker.attach_tracker(tracker); @@ -3570,9 +3591,17 @@ mod tests { let exposure = startup .find("let handle = WorkerHandle") .expect("controller handle exposure"); + let materialize_gate = startup[..materialize] + .rfind("if runtime_managed && worker.needs_initial_session_head_materialization() {") + .expect("fresh runtime-managed materialization gate"); - assert!(feature_install < materialize); + assert!(feature_install < materialize_gate); + assert!(materialize_gate < materialize); assert!(materialize < exposure); + assert!( + startup[materialize_gate..materialize] + .contains("needs_initial_session_head_materialization") + ); } #[test] @@ -3639,7 +3668,7 @@ mod tests { } #[test] - fn automatic_memory_extraction_destinations_are_mutually_exclusive() { + fn legacy_and_subjektiv_lifecycles_are_mutually_exclusive() { let mut memory = manifest::MemoryFeatureProfileConfig::default(); memory.enabled = true; memory.extraction.enabled = true; @@ -3647,12 +3676,17 @@ mod tests { subjektiv.enabled = true; subjektiv.extraction.enabled = true; - let error = validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap_err(); + let error = validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!(error.to_string().contains("cannot both be enabled")); - subjektiv.extraction.enabled = false; - validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap(); + memory.extraction.enabled = false; + assert!( + validate_memory_lifecycle_targets(&memory, &subjektiv).is_err(), + "legacy consolidation still installs the legacy lifecycle" + ); + memory.consolidation.request_enabled = false; + validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap(); } #[test] diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 85f8936da..e7f6fd0fe 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -650,6 +650,22 @@ struct WorkspaceResidentSummarySource { client: Arc, } +struct WorkspaceSubjektivResidentSummarySource { + client: Arc, +} + +const READY_EMPTY_RESIDENT_SURFACE: &str = + "The current ready resident memory surface is intentionally empty."; + +fn resident_summary_contribution( + output: memory::backend::MemoryResidentSummaryOutput, +) -> SystemPromptContribution { + if output.availability != memory::backend::MemoryResidentSummaryAvailability::Ready { + return SystemPromptContribution::Unavailable; + } + SystemPromptContribution::Ready(output.content.unwrap_or_default()) +} + #[async_trait] impl SystemPromptContributionSource for WorkspaceResidentSummarySource { async fn load(&self) -> SystemPromptContribution { @@ -662,14 +678,8 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { ) .await { - Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) - if output.availability - == memory::backend::MemoryResidentSummaryAvailability::Ready => - { - SystemPromptContribution::Ready(output.content.unwrap_or_default()) - } - Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(_)) => { - SystemPromptContribution::Unavailable + Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) => { + resident_summary_contribution(output) } Ok(other) => { tracing::debug!(?other, "unexpected resident Memory Backend result"); @@ -683,11 +693,39 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } } +#[async_trait] +impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { + async fn load(&self) -> SystemPromptContribution { + match execute_subjektiv_memory_operation( + self.client.as_ref(), + server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), + ) + .await + { + Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { + match resident_summary_contribution(output) { + SystemPromptContribution::Ready(content) if content.trim().is_empty() => { + SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) + } + contribution => contribution, + } + } + Ok(other) => { + tracing::debug!(?other, "unexpected subject resident Memory response"); + SystemPromptContribution::Unavailable + } + Err(error) => { + tracing::debug!(%error, "subject resident Memory unavailable"); + SystemPromptContribution::Unavailable + } + } + } +} + pub(crate) struct MemoryFeatureInstallPlan { pub(crate) module: MemoryToolsFeature, pub(crate) resident_summary_source: Option>, pub(crate) system_prompt_override: Option, - pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig, } impl MemoryFeatureInstallPlan { @@ -720,11 +758,7 @@ impl MemoryFeatureInstallPlan { } => Some(name.as_str()), _ => None, }); - let memory_consolidation_worker = matches!( - profile_name, - Some("memory-consolidation" | "subjektiv-memory-consolidation") - ); - let subjektiv_consolidation_worker = profile_name == Some("subjektiv-memory-consolidation"); + let memory_consolidation_worker = profile_name == Some("memory-consolidation"); config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; @@ -758,31 +792,170 @@ impl MemoryFeatureInstallPlan { let system_prompt_override = if memory_consolidation_worker { let language = settings.language; Some( - if subjektiv_consolidation_worker { - prompts.subjektiv_memory_consolidation_system(&language) - } else { - prompts.memory_consolidation_system(&language) - } - .map_err(|error| std::io::Error::other(error.to_string()))?, + prompts + .memory_consolidation_system(&language) + .map_err(|error| std::io::Error::other(error.to_string()))?, ) } else { None }; - let module = if subjektiv_consolidation_worker { - MemoryToolsFeature::from_tools(workspace_http_subjektiv_consolidation_tools(client)) - } else { - MemoryToolsFeature::new(client, config.profile.staging_tools) - }; + let module = MemoryToolsFeature::new(client, config.profile.staging_tools); Ok(Some(Self { module, resident_summary_source, system_prompt_override, - resolved_config: config, })) } } +pub(crate) fn is_builtin_subjektiv_consolidation_profile( + manifest: &manifest::WorkerManifest, +) -> bool { + manifest.profile.as_ref().is_some_and(|snapshot| { + matches!( + &snapshot.source, + manifest::ProfileSource::Registry { + source: manifest::ProfileRegistrySource::Builtin, + name, + .. + } if name == "subjektiv-memory-consolidation" + ) + }) +} + +pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool { + manifest.feature.subjektiv.profile.enabled + && !is_builtin_subjektiv_consolidation_profile(manifest) +} + +pub(crate) fn ordinary_subjektiv_resident_summary_source( + manifest: &manifest::WorkerManifest, + client: Arc, +) -> std::io::Result>> { + if !ordinary_subjektiv_features_enabled(manifest) { + return Ok(None); + } + manifest + .feature + .subjektiv + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !client.is_available() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv resident Memory requires Backend Workspace API authority", + )); + } + let workspace_id = client.workspace_id().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv resident Memory requires Backend Workspace API authority", + ) + })?; + let settings = manifest + .feature + .subjektiv + .workspace_settings() + .expect("validated enabled subjektiv config has Workspace settings"); + if settings.workspace_id != workspace_id { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "subjektiv Workspace settings belong to {} instead of {}", + settings.workspace_id, workspace_id + ), + )); + } + Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { + client, + }))) +} + +pub(crate) struct SubjektivConsolidationFeatureInstallPlan { + pub(crate) module: SubjektivConsolidationToolsFeature, + pub(crate) system_prompt_override: String, +} + +impl SubjektivConsolidationFeatureInstallPlan { + pub(crate) fn prepare( + manifest: &manifest::WorkerManifest, + client: Arc, + prompts: Arc, + ) -> std::io::Result> { + if !is_builtin_subjektiv_consolidation_profile(manifest) { + return Ok(None); + } + let config = &manifest.feature.subjektiv; + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !config.profile.enabled || config.profile.extraction.enabled { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv consolidation profile requires subject settings without automatic extraction", + )); + } + let workspace_id = client.workspace_id().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv consolidation tools require Backend Workspace API authority", + ) + })?; + let settings = config + .workspace_settings() + .expect("validated enabled subjektiv config has Workspace settings"); + if settings.workspace_id != workspace_id { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "subjektiv settings belong to {} instead of {}", + settings.workspace_id, workspace_id + ), + )); + } + let system_prompt_override = prompts + .subjektiv_memory_consolidation_system(&settings.language) + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(Some(Self { + module: SubjektivConsolidationToolsFeature { + tools: workspace_http_subjektiv_consolidation_tools(client), + }, + system_prompt_override, + })) + } +} + +#[derive(Clone)] +pub(crate) struct SubjektivConsolidationToolsFeature { + tools: Vec, +} + +impl FeatureModule for SubjektivConsolidationToolsFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin( + "subjektiv-memory-consolidation", + "subjektiv Memory Consolidation", + ) + .with_description("Dedicated subject Memory staging consolidation tools."); + for tool in &self.tools { + let (meta, _) = tool(); + descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description)); + } + descriptor + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for tool in &self.tools { + let (meta, _) = tool(); + context + .tools() + .register(ToolContribution::new(meta.name, tool.clone()))?; + } + Ok(()) + } +} + #[derive(Clone)] pub struct MemoryToolsFeature { tools: Vec, @@ -797,10 +970,6 @@ impl MemoryToolsFeature { }; Self { tools } } - - fn from_tools(tools: Vec) -> Self { - Self { tools } - } } impl FeatureModule for MemoryToolsFeature { @@ -828,7 +997,9 @@ impl FeatureModule for MemoryToolsFeature { #[cfg(test)] mod tests { use super::*; + use crate::WorkspaceResponse; use agen::tool::ToolDefinition; + use std::sync::Mutex; fn test_client() -> Arc { Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -837,13 +1008,100 @@ mod tests { )) } + #[derive(Debug)] + struct SubjectResidentClient { + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option, + scope_allowed: bool, + paths: Mutex>, + } + + impl SubjectResidentClient { + fn new( + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option<&str>, + ) -> Self { + Self { + availability, + content: content.map(str::to_string), + scope_allowed: true, + paths: Mutex::new(Vec::new()), + } + } + + fn denied() -> Self { + Self { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some("must not be injected".into()), + scope_allowed: false, + paths: Mutex::new(Vec::new()), + } + } + } + + impl WorkspaceClient for SubjectResidentClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + + fn kind(&self) -> &str { + "subject-resident-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.paths.lock().unwrap().push(request.path.clone()); + assert!(request.path.ends_with("/subjektiv/memory")); + let request: server_api::SubjektivMemoryBackendRequest = + serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); + assert!(matches!( + request.operation, + server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + )); + if !self.scope_allowed { + return Ok(WorkspaceResponse { + status: 403, + body: "subject scope unavailable".into(), + }); + } + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string( + &server_api::SubjektivMemoryBackendResponse::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, + ), + ) + .unwrap(), + }) + } + } + fn resident_client(content: &str) -> Arc { + resident_client_state( + memory::backend::MemoryResidentSummaryAvailability::Ready, + Some(content), + ) + } + + fn resident_client_state( + availability: memory::backend::MemoryResidentSummaryAvailability, + content: Option<&str>, + ) -> Arc { use std::io::{Read, Write}; use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); - let content = content.to_string(); + let content = content.map(str::to_string); std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0_u8; 1024]; @@ -852,7 +1110,7 @@ mod tests { "status": "ok", "result": { "kind": "resident_summary", - "availability": "ready", + "availability": availability, "content": content, } }) @@ -989,6 +1247,60 @@ mod tests { ); } + #[test] + fn dedicated_subjektiv_consolidator_uses_subject_settings_and_only_consolidation_tools() { + let workspace = tempfile::tempdir().unwrap(); + let mut manifest = manifest::ProfileResolver::new() + .with_workspace_base(workspace.path()) + .resolve( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + "subjektiv-memory-consolidation", + ), + manifest::ProfileResolveOptions::with_worker_name("subject-consolidator"), + ) + .unwrap() + .manifest; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace".into(), + settings_revision: 1, + language: "English".into(), + }) + .unwrap(); + + assert!(!manifest.feature.memory.profile.enabled); + assert!(!ordinary_subjektiv_features_enabled(&manifest)); + assert!( + MemoryFeatureInstallPlan::prepare( + &manifest, + test_client(), + crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), + ) + .unwrap() + .is_none() + ); + + let plan = SubjektivConsolidationFeatureInstallPlan::prepare( + &manifest, + test_client(), + crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), + ) + .unwrap() + .unwrap(); + assert_eq!( + plan.module.descriptor().id.as_str(), + "builtin:subjektiv-memory-consolidation" + ); + let names = tool_names(plan.module.tools); + assert!(names.contains(&"SubjektivMemoryQuery".to_string())); + assert!(names.contains(&"MemoryApplyCandidate".to_string())); + assert!(!names.contains(&"MemoryQuery".to_string())); + assert!(!names.contains(&"SubjektivRemember".to_string())); + } + #[tokio::test] async fn memory_prompt_contribution_defers_resident_summary_until_loaded() { let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(); @@ -1029,6 +1341,132 @@ mod tests { ); } + fn subject_manifest(workspace_id: &str) -> manifest::WorkerManifest { + let mut manifest = manifest::WorkerManifest::from_toml( + r#" +[worker] +name = "subject-worker" +scope = "main" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/subject-resident-test" +permission = "write" +"#, + ) + .unwrap(); + manifest.feature.subjektiv.profile.enabled = true; + manifest + .feature + .subjektiv + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: workspace_id.to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); + manifest + } + + #[tokio::test] + async fn subject_resident_source_preserves_only_current_ready_surface() { + let manifest = subject_manifest("workspace"); + let ready_client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ready, + Some("current subject surface"), + )); + let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) + .unwrap() + .unwrap(); + assert_eq!( + ready.load().await, + SystemPromptContribution::Ready("current subject surface".into()) + ); + assert_eq!( + ready_client.paths.lock().unwrap().clone(), + vec!["/api/w/workspace/subjektiv/memory".to_string()] + ); + + let ready_empty_client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ready, + None, + )); + let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) + .unwrap() + .unwrap(); + assert_eq!( + ready_empty.load().await, + SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) + ); + + for availability in [ + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + memory::backend::MemoryResidentSummaryAvailability::Stale, + memory::backend::MemoryResidentSummaryAvailability::Failed, + ] { + let source = ordinary_subjektiv_resident_summary_source( + &manifest, + Arc::new(SubjectResidentClient::new( + availability, + Some("must not be injected"), + )), + ) + .unwrap() + .unwrap(); + assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + } + } + + #[tokio::test] + async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::denied()); + let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) + .unwrap() + .unwrap(); + + assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + assert_eq!( + client.paths.lock().unwrap().clone(), + vec!["/api/w/workspace/subjektiv/memory".to_string()] + ); + } + + #[test] + fn subject_resident_source_requires_trusted_matching_workspace_config() { + let disabled = manifest::WorkerManifest::from_toml( + r#" +[worker] +name = "ordinary-worker" +scope = "main" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/subject-resident-test" +permission = "write" +"#, + ) + .unwrap(); + assert!( + ordinary_subjektiv_resident_summary_source(&disabled, test_client()) + .unwrap() + .is_none() + ); + + let foreign = subject_manifest("other-workspace"); + assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err()); + } + #[test] fn memory_feature_owns_normal_and_staging_tool_surfaces() { let normal = MemoryToolsFeature::new(test_client(), false); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index d645a1670..01a64eb94 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -49,6 +49,35 @@ enum ExtractionTarget { Subjektiv, } +#[derive(Clone)] +struct LifecycleConfig { + extraction: manifest::MemoryExtractionProfileConfig, + consolidation_request_enabled: bool, + workspace_settings: Option, +} + +impl LifecycleConfig { + fn from_memory(config: manifest::ResolvedMemoryFeatureConfig) -> Self { + Self { + extraction: config.profile.extraction, + consolidation_request_enabled: config.profile.consolidation.request_enabled, + workspace_settings: config.workspace_settings, + } + } + + fn from_subjektiv(config: manifest::ResolvedSubjektivFeatureConfig) -> Self { + Self { + extraction: config.profile.extraction, + consolidation_request_enabled: true, + workspace_settings: config.workspace_settings, + } + } + + fn workspace_settings(&self) -> Option { + self.workspace_settings.clone() + } +} + /// Parent-Worker lifecycle Feature that observes committed runs and schedules /// bounded extraction work. It owns the Memory pointer, audit, restricted /// Internal Worker, and staging disposition; Worker core owns only generic @@ -67,7 +96,7 @@ pub(crate) struct SubjektivLifecycleFeature { #[derive(Clone)] struct MemoryLifecycleTask { - config: manifest::ResolvedMemoryFeatureConfig, + config: LifecycleConfig, target: ExtractionTarget, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, @@ -141,7 +170,7 @@ impl MemoryLifecycleFeature { ) -> Self { Self { task: MemoryLifecycleTask { - config, + config: LifecycleConfig::from_memory(config), target: ExtractionTarget::WorkspaceMemory, capture, extensions, @@ -170,7 +199,7 @@ impl SubjektivLifecycleFeature { workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled || !config.profile.enabled { + if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { return Ok(None); } config @@ -182,23 +211,9 @@ impl SubjektivLifecycleFeature { "subjektiv extraction requires Backend Workspace API authority", )); } - let memory_config = manifest::ResolvedMemoryFeatureConfig { - profile: manifest::MemoryFeatureProfileConfig { - enabled: true, - staging_tools: false, - resident: manifest::MemoryResidentProfileConfig { - inject_summary: false, - }, - extraction: config.profile.extraction, - consolidation: manifest::MemoryConsolidationProfileConfig { - request_enabled: false, - }, - }, - workspace_settings: config.workspace_settings, - }; Ok(Some(Self { task: MemoryLifecycleTask { - config: memory_config, + config: LifecycleConfig::from_subjektiv(config), target: ExtractionTarget::Subjektiv, capture, extensions, @@ -282,10 +297,10 @@ impl MemoryLifecycleTask { fn extraction_manifest(&self) -> WorkerManifest { let mut manifest = self.manifest.clone(); - if let Some(model) = self.config.profile.extraction.model.clone() { + if let Some(model) = self.config.extraction.model.clone() { manifest.model = model; } - if let Some(reasoning) = self.config.profile.extraction.reasoning.clone() { + if let Some(reasoning) = self.config.extraction.reasoning.clone() { manifest.engine.reasoning = Some(reasoning); } manifest @@ -301,14 +316,13 @@ impl MemoryLifecycleTask { memory::audit::AuditWorker::MemoryExtract, memory::audit::AuditTrigger::TokenThreshold, self.config - .profile .extraction .model .as_ref() .or(Some(&self.manifest.model)) .map(model_audit_from_manifest), ) - .with_memory_settings(&self.config); + .with_workspace_settings(self.config.workspace_settings.as_ref()); let audit = if self.target == ExtractionTarget::Subjektiv { audit.without_legacy_backend() } else { @@ -364,7 +378,6 @@ impl MemoryLifecycleTask { }; let Some(threshold) = self .config - .profile .extraction .threshold .filter(|threshold| *threshold > 0) @@ -470,7 +483,7 @@ impl MemoryLifecycleTask { capture.session_id.clone(), ), }; - let client = if let Some(model) = self.config.profile.extraction.model.as_ref() { + let client = if let Some(model) = self.config.extraction.model.as_ref() { match crate::model_client::build_client(model) { Ok(client) => client, Err(error) => { @@ -534,7 +547,6 @@ impl MemoryLifecycleTask { cache_key: Some(capture.segment_id.clone()), max_turns: self .config - .profile .extraction .worker_max_turns .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), @@ -710,7 +722,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { context: BackgroundTaskContext, cancellation: BackgroundTaskCancellation, ) -> Result<(), HookError> { - let extraction = if self.config.profile.extraction.enabled { + let extraction = if self.config.extraction.enabled { self.run_extraction(context.clone(), cancellation.clone()) .await } else { @@ -719,9 +731,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask { if !cancellation.is_cancelled() { context.generation_fence.ensure_current()?; match self.target { - ExtractionTarget::WorkspaceMemory - if self.config.profile.consolidation.request_enabled => - { + ExtractionTarget::WorkspaceMemory if self.config.consolidation_request_enabled => { self.request_consolidation(false).await; } ExtractionTarget::Subjektiv => { @@ -770,7 +780,7 @@ impl MemoryLifecycleTask { memory::audit::AuditTrigger::StagingBacklog, Some(model_audit_from_manifest(&self.manifest.model)), ) - .with_memory_settings(&self.config); + .with_workspace_settings(self.config.workspace_settings.as_ref()); let operation = memory::backend::MemoryConsolidateStagingOperation { force: false }; let request = if subjektiv { self.workspace_client @@ -981,15 +991,15 @@ impl WorkerAuditBase { self } - fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self { - self.memory_settings = - config - .workspace_settings() - .map(|snapshot| memory::audit::MemorySettingsAudit { - workspace_id: snapshot.workspace_id, - settings_revision: snapshot.settings_revision, - language: snapshot.language, - }); + fn with_workspace_settings( + mut self, + settings: Option<&manifest::WorkspaceMemorySettingsSnapshot>, + ) -> Self { + self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit { + workspace_id: snapshot.workspace_id.clone(), + settings_revision: snapshot.settings_revision, + language: snapshot.language.clone(), + }); self } @@ -1407,7 +1417,7 @@ permission = "write" Ok(true) }); MemoryLifecycleTask { - config: test_config(), + config: LifecycleConfig::from_memory(test_config()), target: ExtractionTarget::WorkspaceMemory, capture: capture_handle, extensions, @@ -1483,6 +1493,35 @@ permission = "write" assert!(feature.is_none()); } + #[test] + fn subjektiv_without_extraction_installs_no_session_lifecycle() { + let capture = CommittedSessionCaptureHandle::new(|| { + panic!("subjektiv without extraction must not capture a Session") + }); + let extensions = SessionExtensionHandle::new(|_, _, _| { + panic!("subjektiv without extraction must not write a pointer") + }); + let mut config = manifest::ResolvedSubjektivFeatureConfig::default(); + config.profile.enabled = true; + config.profile.extraction.enabled = false; + + let feature = SubjektivLifecycleFeature::from_resolved_config( + true, + config, + capture, + extensions, + crate::worker::marker_workspace_client(None, "extraction-disabled"), + test_manifest(), + Box::new(ScriptClient::new(Vec::new())), + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), + WorkerWorkspaceContext::no_workspace(), + None, + ) + .unwrap(); + + assert!(feature.is_none()); + } + #[test] fn child_host_suppresses_enabled_subjektiv_lifecycle_without_capture_side_effects() { let capture = CommittedSessionCaptureHandle::new(|| { @@ -1536,12 +1575,11 @@ permission = "write" event_tx, workspace_client, ); - task.config.profile.extraction.model = Some(manifest::ModelManifest { + task.config.extraction.model = Some(manifest::ModelManifest { ref_: Some("codex-oauth/gpt-5.6-luna".to_string()), ..Default::default() }); - task.config.profile.extraction.reasoning = - Some(ReasoningControl::Effort(ReasoningEffort::Medium)); + task.config.extraction.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium)); let manifest = task.extraction_manifest(); assert_eq!( @@ -1658,7 +1696,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let writes = extension_writes.lock().unwrap(); @@ -1711,7 +1749,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let writes = extension_writes.lock().unwrap(); @@ -1747,7 +1785,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; let registry = start_background_task(task); tokio::time::timeout(Duration::from_secs(5), async { while !workspace_client.session_started.load(Ordering::Acquire) { @@ -1797,7 +1835,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; assert!(extension_writes.lock().unwrap().is_empty()); @@ -1914,7 +1952,7 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; let registry = start_background_task(task); tokio::time::timeout(Duration::from_secs(5), async { while calls.load(Ordering::SeqCst) == 0 { @@ -2011,7 +2049,7 @@ permission = "write" event_tx, workspace_client.clone(), ); - task.config.profile.consolidation.request_enabled = false; + task.config.consolidation_request_enabled = false; run_background_task(task).await; let requests = workspace_client.requests.lock().unwrap(); @@ -2041,8 +2079,8 @@ permission = "write" event_tx, workspace_client.clone(), ); - task.config.profile.extraction.enabled = false; - task.config.profile.consolidation.request_enabled = true; + task.config.extraction.enabled = false; + task.config.consolidation_request_enabled = true; run_background_task(task).await; assert!(extension_writes.lock().unwrap().is_empty()); diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index 730b60c7e..a314628a7 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -14,7 +14,9 @@ use crate::feature::background::{ BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger, FeatureBackgroundTask, }; -use crate::feature::builtin::memory::WorkspaceMemoryBackendError; +use crate::feature::builtin::memory::{ + WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile, +}; use crate::feature::builtin::memory_surface_output::{ MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition, }; @@ -98,19 +100,15 @@ impl SubjektivSurfaceLifecycleFeature { prompts: Arc>, workspace_context: WorkerWorkspaceContext, ) -> std::io::Result> { - let dedicated = manifest.profile.as_ref().is_some_and(|snapshot| { - matches!( - &snapshot.source, - manifest::ProfileSource::Registry { - source: manifest::ProfileRegistrySource::Builtin, - name, - .. - } if name == "subjektiv-memory-consolidation" - ) - }); + let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest); if !lifecycle_enabled || !dedicated { return Ok(None); } + manifest + .feature + .subjektiv + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -260,13 +258,13 @@ impl SubjektivSurfaceLifecycleTask { let language = self .manifest .feature - .memory + .subjektiv .workspace_settings() .map(|settings| settings.language) .ok_or_else(|| { SurfaceEditorFailure::editor(HookError::new( HookErrorCategory::Internal, - "surface editor requires bound Workspace Memory settings", + "surface editor requires bound subjektiv Workspace settings", )) })?; let system_prompt = self @@ -721,10 +719,10 @@ permission = "write" "#, ) .unwrap(); - manifest.feature.memory.profile.enabled = true; + manifest.feature.subjektiv.profile.enabled = true; manifest .feature - .memory + .subjektiv .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-1".into(), settings_revision: 1, diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 75d6e8ee4..ad49fd8fb 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1044,16 +1044,25 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() { ProfileResolveOptions::with_worker_name("orchestrator-worker"), ) .unwrap(); + let settings = manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-test".to_string(), + settings_revision: 1, + language: "English".to_string(), + }; if resolved.manifest.feature.memory.enabled() { resolved .manifest .feature .memory - .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-test".to_string(), - settings_revision: 1, - language: "English".to_string(), - }) + .bind_workspace_settings(settings.clone()) + .unwrap(); + } + if resolved.manifest.feature.subjektiv.enabled() { + resolved + .manifest + .feature + .subjektiv + .bind_workspace_settings(settings) .unwrap(); } let workspace_context = diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e8e3c16ee..f7db02875 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -20,8 +20,8 @@ use config_source::ConfigTreeSnapshot; use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ - MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult, - MemoryConsolidateStagingOperation, MemoryConsolidationOutput, + MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, + MemoryConsolidationOutput, }; use protocol::Segment; use protocol::stream::{decode_method, encode_event}; @@ -5433,6 +5433,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::memory_document( service.clone(), )) + .merge(server_api::server_api_axum::legacy_memory_reset( + service.clone(), + )) .merge(server_api::server_api_axum::memory_staging_list( service.clone(), )) @@ -5443,9 +5446,24 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_create( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_list( + service.clone(), + )) .merge(server_api::server_api_axum::subjektiv_subject_get( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_resident_surface( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_list( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_memory_revisions( + service.clone(), + )) .merge(server_api::server_api_axum::subjektiv_subject_retire( service.clone(), )) @@ -5464,6 +5482,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_session_backend( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_memory_consolidation( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -6984,6 +7005,24 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn legacy_memory_reset( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::LegacyMemoryResetRequest, + ) -> std::result::Result + { + scoped_reset_legacy_memory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn memory_staging_list( &self, workspace_id: String, @@ -7056,6 +7095,22 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_list( + &self, + workspace_id: String, + query: server_api::SubjektivSubjectListQuery, + ) -> std::result::Result + { + scoped_list_subjektiv_subjects( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_subject_get( &self, workspace_id: String, @@ -7074,6 +7129,92 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_resident_surface( + &self, + workspace_id: String, + subject_id: String, + ) -> std::result::Result< + server_api::SubjektivResidentSurfaceResponse, + server_api::RepositoryApiError, + > { + scoped_get_subjektiv_resident_surface( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_list( + &self, + workspace_id: String, + subject_id: String, + query: server_api::SubjektivMemoryListQuery, + ) -> std::result::Result + { + scoped_list_subjektiv_memories( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + Query(query.into()), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_detail( + &self, + workspace_id: String, + subject_id: String, + memory_id: String, + query: server_api::SubjektivMemoryDetailQuery, + ) -> std::result::Result + { + scoped_get_subjektiv_memory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id, + subject_id, + memory_id, + }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_memory_revisions( + &self, + workspace_id: String, + subject_id: String, + memory_id: String, + query: server_api::SubjektivMemoryRevisionsQuery, + ) -> std::result::Result< + server_api::SubjektivMemoryListRevisionsResponse, + server_api::RepositoryApiError, + > { + scoped_list_subjektiv_memory_revisions( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id, + subject_id, + memory_id, + }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_subject_retire( &self, workspace_id: String, @@ -10435,6 +10576,13 @@ struct ScopedSubjektivSubjectPath { subject_id: String, } +#[derive(Debug, Clone, Deserialize)] +struct ScopedSubjektivMemoryPath { + workspace_id: String, + subject_id: String, + memory_id: String, +} + #[derive(Debug, Deserialize)] struct ScopedFlowPath { workspace_id: String, @@ -16540,6 +16688,35 @@ async fn scoped_get_memory_document( })) } +async fn scoped_reset_legacy_memory( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Legacy Workspace Memory reset", + ) + .await?; + let expected = format!("RESET LEGACY MEMORY {}", path.workspace_id); + if request.confirmation != expected { + return Err(Error::InvalidInput(format!( + "legacy Memory reset confirmation must exactly equal `{expected}`" + )) + .into()); + } + let counts = api.store.reset_legacy_memory(&path.workspace_id)?; + Ok(Json(server_api::LegacyMemoryResetResponse { + workspace_id: path.workspace_id, + documents_deleted: counts.documents, + staging_records_deleted: counts.staging_records, + staging_resolutions_deleted: counts.staging_resolutions, + })) +} + async fn scoped_list_memory_staging( State(api): State, AxumPath(path): AxumPath, @@ -16557,16 +16734,11 @@ async fn scoped_list_memory_staging( async fn scoped_memory_backend_operation( State(api): State, AxumPath(path): AxumPath, - context: server_api::ServerRequestContext, + _context: server_api::ServerRequestContext, Json(operation): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let result = match operation { - MemoryBackendOperation::ResidentSummary(_) => { - execute_scoped_resident_summary(&api, &context) - } - operation => execute_memory_backend_operation_with_authority(&api.authority, operation), - }; + let result = execute_memory_backend_operation_with_authority(&api.authority, operation); let response = match result { Ok(result) => MemoryBackendHttpResponse::Ok { result }, Err(error) => MemoryBackendHttpResponse::Error { @@ -16576,48 +16748,6 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } -fn execute_scoped_resident_summary( - api: &WorkspaceApi, - context: &server_api::ServerRequestContext, -) -> crate::Result { - let Some(source) = context.runtime_source.as_ref() else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let Some(worker_id) = source.worker_id.as_deref() else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); - let Some(lease) = api - .store - .require_current_worker_singleton_owner(&api.config.workspace_id, &worker)? - else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let Some(subject_id) = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else { - return execute_memory_backend_operation_with_authority( - &api.authority, - MemoryBackendOperation::ResidentSummary(Default::default()), - ); - }; - let resident = - crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) - .map_err(|error| Error::Store(error.to_string()))? - .resident_surface(subject_id) - .map_err(|error| Error::Store(error.to_string()))?; - Ok(MemoryBackendOperationResult::ResidentSummary( - resident_summary_output(resident), - )) -} - fn resident_summary_output( resident: crate::subjektiv::ResidentSurface, ) -> memory::backend::MemoryResidentSummaryOutput { @@ -16852,6 +16982,34 @@ async fn scoped_create_subjektiv_subject( )) } +async fn scoped_list_subjektiv_subjects( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let limit = query.limit.unwrap_or(20); + if limit == 0 || limit > server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT { + return Err(Error::InvalidInput(format!( + "subject list limit must be within 1..={}", + server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT + )) + .into()); + } + let page = open_subjektiv_store(&api)? + .list_subjects(limit) + .map_err(subjektiv_store_error)?; + Ok(Json(server_api::SubjektivSubjectListResponse { + limit, + items: page + .items + .into_iter() + .map(|subject| subjektiv_subject_response(&api, subject)) + .collect(), + has_more: page.has_more, + })) +} + async fn scoped_get_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, @@ -16864,6 +17022,123 @@ async fn scoped_get_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } +fn require_subjektiv_subject( + store: &crate::subjektiv::SubjektivStore, + subject_id: &str, +) -> ApiResult<()> { + if store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .is_none() + { + return Err(Error::SubjektivSubjectNotFound(subject_id.to_string()).into()); + } + Ok(()) +} + +async fn scoped_get_subjektiv_resident_surface( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + let resident = store + .resident_surface(&path.subject_id) + .map_err(subjektiv_store_error)?; + let availability = match resident.availability { + crate::subjektiv::SurfaceAvailability::Ungenerated => { + server_api::SubjektivResidentSurfaceAvailability::Ungenerated + } + crate::subjektiv::SurfaceAvailability::Stale => { + server_api::SubjektivResidentSurfaceAvailability::Stale + } + crate::subjektiv::SurfaceAvailability::Failed => { + server_api::SubjektivResidentSurfaceAvailability::Failed + } + crate::subjektiv::SurfaceAvailability::Ready => { + server_api::SubjektivResidentSurfaceAvailability::Ready + } + }; + let snapshot = resident + .snapshot + .map(|snapshot| server_api::SubjektivResidentSurfaceSnapshot { + snapshot_id: snapshot.id, + body_md: snapshot.body_md, + memory_refs: snapshot + .memory_refs + .into_iter() + .map(|reference| server_api::SubjektivMemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + built_from_store_revision: snapshot.built_from_store_revision, + created_at: snapshot.created_at, + }); + Ok(Json(server_api::SubjektivResidentSurfaceResponse { + subject_id: path.subject_id, + availability, + snapshot, + })) +} + +async fn scoped_list_subjektiv_memories( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_query( + &store, + &path.subject_id, + query, + )?)) +} + +async fn scoped_get_subjektiv_memory( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_read( + &store, + &path.subject_id, + server_api::SubjektivMemoryReadRequest { + memory_id: path.memory_id, + revision: query.revision, + offset: query.offset, + byte_offset: query.byte_offset, + limit: query.limit, + evidence_cursor: query.evidence_cursor, + }, + )?)) +} + +async fn scoped_list_subjektiv_memory_revisions( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_list_revisions( + &store, + &path.subject_id, + server_api::SubjektivMemoryListRevisionsRequest { + memory_id: path.memory_id, + limit: query.limit, + cursor: query.cursor, + }, + )?)) +} + async fn scoped_retire_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, @@ -16871,7 +17146,7 @@ async fn scoped_retire_subjektiv_subject( validate_workspace_scope(&api, &path.workspace_id)?; let subject = open_subjektiv_store(&api)? .retire_subject(&path.subject_id) - .map_err(|error| Error::InvalidInput(error.to_string()))?; + .map_err(subjektiv_store_error)?; // Retirement deliberately does not stop or remove the current Worker. The // Worker lease and subject lifecycle are independent authorities. Ok(Json(subjektiv_subject_response(&api, subject))) @@ -18357,6 +18632,19 @@ async fn scoped_subjektiv_memory_backend( let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; let store = open_subjektiv_store(&api)?; let response = match request.operation { + server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "resident surface read", + )?; + let resident = store + .resident_surface(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ResidentSummary(resident_summary_output( + resident, + )) + } server_api::SubjektivMemoryBackendOperation::Query(input) => { server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( &store, @@ -19738,9 +20026,12 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { | crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => { Error::RepositoryConflict(format!("candidate_decision_conflict: {error}")) } - crate::subjektiv::SubjektivError::MemoryNotFound(_) => { - Error::InvalidInput(format!("memory_not_found: {error}")) + crate::subjektiv::SubjektivError::SubjectNotFound(subject_id) => { + Error::SubjektivSubjectNotFound(subject_id) } + crate::subjektiv::SubjektivError::MemoryNotFound(memory_id) => Error::InvalidInput( + format!("memory_not_found: Memory `{memory_id}` was not found"), + ), crate::subjektiv::SubjektivError::Storage(_) => Error::Store(error.to_string()), _ => Error::InvalidInput(error.to_string()), } @@ -31302,7 +31593,14 @@ fn api_error_status(error: &Error) -> StatusCode { | Error::RuntimeBindingRevisionConflict { .. } | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, + Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, + Error::InvalidInput(message) + if message.starts_with("memory_not_found:") + || message.starts_with("memory_revision_not_found:") => + { + StatusCode::NOT_FOUND + } + Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { StatusCode::BAD_REQUEST } @@ -38007,6 +38305,398 @@ mod tests { assert_eq!(chunks, content); } + #[tokio::test] + async fn legacy_memory_reset_requires_owner_confirmation_and_preserves_subjektiv() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + api.store + .upsert_memory_document(&crate::store::MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Legacy Memory\n\nRetained until explicit reset.\n".to_string(), + created_at: "2026-10-02T00:00:00Z".to_string(), + updated_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + api.store + .upsert_memory_staging_record(&crate::store::MemoryStagingRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + candidate_id: "legacy-pending".to_string(), + raw_json: r#"{"claim":"pending"}"#.to_string(), + source_path: None, + imported_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + api.store + .insert_memory_staging_resolution(&crate::store::MemoryStagingResolutionRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + candidate_id: "legacy-resolved".to_string(), + action: "discarded".to_string(), + reason: "fixture".to_string(), + affected_refs_json: "[]".to_string(), + staging_raw_json: r#"{"claim":"resolved"}"#.to_string(), + source_path: None, + imported_at: "2026-10-02T00:00:00Z".to_string(), + resolved_at: "2026-10-02T00:01:00Z".to_string(), + }) + .unwrap(); + + let subjektiv = open_subjektiv_store(&api).unwrap(); + let subject = subjektiv + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let memory = subjektiv + .create_memory( + &subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Preserve feature Memory", + "This record is outside the legacy reset scope.", + "Proves reset isolation", + "fixture", + ), + ) + .unwrap(); + + let incorrect = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: "RESET LEGACY MEMORY wrong-workspace".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(incorrect.into_response().status(), StatusCode::BAD_REQUEST); + assert!( + api.store + .get_memory_document(TEST_WORKSPACE_ID) + .unwrap() + .is_some() + ); + + let mut non_owner = test_owner_actor(); + non_owner.account_id = "another-account".to_string(); + let forbidden = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(non_owner), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap_err(); + assert_eq!(forbidden.into_response().status(), StatusCode::FORBIDDEN); + + let Json(reset) = scoped_reset_legacy_memory( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap(); + assert_eq!(reset.documents_deleted, 1); + assert_eq!(reset.staging_records_deleted, 1); + assert_eq!(reset.staging_resolutions_deleted, 1); + assert!( + api.store + .get_memory_document(TEST_WORKSPACE_ID) + .unwrap() + .is_none() + ); + assert_eq!( + subjektiv + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .revision, + 1 + ); + + let Json(repeated) = scoped_reset_legacy_memory( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_owner_actor()), + Json(server_api::LegacyMemoryResetRequest { + confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"), + }), + ) + .await + .unwrap(); + assert_eq!(repeated.documents_deleted, 0); + assert_eq!(repeated.staging_records_deleted, 0); + assert_eq!(repeated.staging_resolutions_deleted, 0); + } + + #[tokio::test] + async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let store = open_subjektiv_store(&api).unwrap(); + let first_subject = store + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let second_subject = store + .create_subject(crate::subjektiv::SubjectRole::new("reviewer").unwrap()) + .unwrap(); + + let invalid_limit = scoped_list_subjektiv_subjects( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }), + ) + .await + .unwrap_err(); + assert_eq!( + invalid_limit.into_response().status(), + StatusCode::BAD_REQUEST + ); + + let Json(subjects) = scoped_list_subjektiv_subjects( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }), + ) + .await + .unwrap(); + assert_eq!(subjects.items.len(), 1); + assert!(subjects.has_more); + + let missing = scoped_get_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: "missing-subject".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND); + assert_eq!(store.list_subjects(100).unwrap().items.len(), 2); + + let Json(subject_detail) = scoped_get_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + ) + .await + .unwrap(); + assert_eq!(subject_detail.id, first_subject.id); + let Json(ungenerated) = scoped_get_subjektiv_resident_surface( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + ) + .await + .unwrap(); + assert_eq!( + ungenerated.availability, + server_api::SubjektivResidentSurfaceAvailability::Ungenerated + ); + assert!(ungenerated.snapshot.is_none()); + + let memory = store + .create_memory( + &first_subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep exact revisions", + "first body", + "Supports historical reads", + "initial", + ), + ) + .unwrap(); + let revised = store + .revise_memory( + &first_subject.id, + &memory.id, + 1, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Lesson, + "Keep exact revisions", + "second body", + "Supports historical reads", + "correction", + ), + ) + .unwrap(); + let other_memory = store + .create_memory( + &second_subject.id, + crate::subjektiv::MemoryDraft::active( + memory::extract::CandidateKind::Decision, + "Keep subjects isolated", + "other body", + "Prevents cross-subject reads", + "initial", + ), + ) + .unwrap(); + + let Json(memories) = scoped_list_subjektiv_memories( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + }), + Query(server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: None, + limit: Some(1), + cursor: None, + }), + ) + .await + .unwrap(); + assert_eq!(memories.items.len(), 1); + assert_eq!(memories.items[0].id, memory.id); + assert_eq!(memories.items[0].revision, 2); + + let missing_memory = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: "missing-memory".to_string(), + }), + Query(server_api::SubjektivMemoryDetailQuery::default()), + ) + .await + .unwrap_err(); + assert_eq!( + missing_memory.into_response().status(), + StatusCode::NOT_FOUND + ); + + let missing_revision = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryDetailQuery { + revision: Some(99), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!( + missing_revision.into_response().status(), + StatusCode::NOT_FOUND + ); + + let cross_subject = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: other_memory.id, + }), + Query(server_api::SubjektivMemoryDetailQuery::default()), + ) + .await + .unwrap_err(); + assert_eq!( + cross_subject.into_response().status(), + StatusCode::FORBIDDEN + ); + + let Json(historical) = scoped_get_subjektiv_memory( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryDetailQuery { + revision: Some(1), + ..Default::default() + }), + ) + .await + .unwrap(); + assert_eq!(historical.revision, 1); + assert_eq!(historical.current_revision, 2); + assert_eq!(historical.body_md, "first body"); + + let Json(revisions) = scoped_list_subjektiv_memory_revisions( + State(api.clone()), + AxumPath(ScopedSubjektivMemoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id.clone(), + memory_id: memory.id.clone(), + }), + Query(server_api::SubjektivMemoryRevisionsQuery { + limit: Some(1), + cursor: None, + }), + ) + .await + .unwrap(); + assert_eq!(revisions.current_revision, 2); + assert_eq!(revisions.items[0].revision, 2); + assert!(revisions.has_more); + + let generation = store.prepare_surface_generation(&first_subject.id).unwrap(); + let expected_ref = crate::subjektiv::MemoryRevisionRef { + memory_id: revised.id.clone(), + revision: revised.revision, + }; + store + .publish_surface_generation( + &first_subject.id, + &generation.id, + vec![crate::subjektiv::SurfacePoint { + body_md: "- Keep exact revisions".to_string(), + memory_refs: vec![expected_ref.clone()], + }], + ) + .unwrap(); + let Json(surface) = scoped_get_subjektiv_resident_surface( + State(api), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: first_subject.id, + }), + ) + .await + .unwrap(); + assert_eq!( + surface.availability, + server_api::SubjektivResidentSurfaceAvailability::Ready + ); + let snapshot = surface.snapshot.unwrap(); + assert_eq!(snapshot.memory_refs.len(), 1); + assert_eq!(snapshot.memory_refs[0].memory_id, expected_ref.memory_id); + assert_eq!(snapshot.memory_refs[0].revision, expected_ref.revision); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); @@ -38064,6 +38754,58 @@ mod tests { origin: None, transport_headers: Vec::new(), }; + api.store + .upsert_memory_document(&MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Legacy sentinel\n".to_string(), + created_at: "2026-10-02T00:00:00Z".to_string(), + updated_at: "2026-10-02T00:00:00Z".to_string(), + }) + .unwrap(); + let Json(legacy_resident) = scoped_memory_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(MemoryBackendOperation::ResidentSummary(Default::default())), + ) + .await + .unwrap(); + assert!(matches!( + legacy_resident, + MemoryBackendHttpResponse::Ok { + result: memory::backend::MemoryBackendOperationResult::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some(ref body), + } + ) + } if body == "# Legacy sentinel\n" + )); + let Json(resident) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentSummary( + Default::default(), + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + resident, + server_api::SubjektivMemoryBackendResponse::ResidentSummary( + memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + } + ) + )); let operation = memory::backend::MemoryStageCandidateOperation { source: memory::schema::SourceRef { segment_id: "segment-1".to_string(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index d2e4b76f9..4d963b05e 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1035,6 +1035,13 @@ pub struct MemoryStagingResolutionRecord { pub resolved_at: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LegacyMemoryResetCounts { + pub documents: usize, + pub staging_records: usize, + pub staging_resolutions: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FlowSourceRecord { pub workspace_id: String, @@ -1450,6 +1457,10 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, limit: usize, ) -> Result>; + /// Atomically removes only the legacy Workspace Memory document and legacy + /// staging rows. Subjektiv Feature storage and all other Workspace state are + /// outside this transaction's deletion set. + fn reset_legacy_memory(&self, workspace_id: &str) -> Result; fn upsert_account(&self, record: &AccountRecord) -> Result<()>; fn get_account(&self, account_id: &str) -> Result>; @@ -5890,6 +5901,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn reset_legacy_memory(&self, workspace_id: &str) -> Result { + validate_identifier("workspace_id", workspace_id)?; + self.with_conn_mut(|connection| { + let transaction = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + // Resolutions retain copies of staging rows, so remove them first; + // pending staging follows, then the singleton legacy document. + let staging_resolutions = transaction.execute( + "DELETE FROM memory_staging_resolutions WHERE workspace_id = ?1", + params![workspace_id], + )?; + let staging_records = transaction.execute( + "DELETE FROM memory_staging_records WHERE workspace_id = ?1", + params![workspace_id], + )?; + let documents = transaction.execute( + "DELETE FROM workspace_memory_documents WHERE workspace_id = ?1", + params![workspace_id], + )?; + transaction.commit()?; + Ok(LegacyMemoryResetCounts { + documents, + staging_records, + staging_resolutions, + }) + }) + } + fn upsert_account(&self, record: &AccountRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -20139,6 +20178,171 @@ INSERT INTO worker_registry ( ); } + #[tokio::test] + async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + for (workspace_id, display_name) in [ + ("local-dev", "Local Dev"), + ("other-workspace", "Other Workspace"), + ] { + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: workspace_id.to_string(), + owner_account_id: "owner-account".to_string(), + display_name: display_name.to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + store + .ensure_memory_document(workspace_id, "# Legacy Memory\n", "2") + .unwrap(); + store + .upsert_memory_staging_record(&MemoryStagingRecord { + workspace_id: workspace_id.to_string(), + candidate_id: format!("candidate-{workspace_id}"), + raw_json: r#"{"claim":"legacy"}"#.to_string(), + source_path: None, + imported_at: "3".to_string(), + }) + .unwrap(); + store + .insert_memory_staging_resolution(&MemoryStagingResolutionRecord { + workspace_id: workspace_id.to_string(), + candidate_id: format!("resolved-{workspace_id}"), + action: "discarded".to_string(), + reason: "fixture".to_string(), + affected_refs_json: "[]".to_string(), + staging_raw_json: r#"{"claim":"resolved"}"#.to_string(), + source_path: None, + imported_at: "3".to_string(), + resolved_at: "4".to_string(), + }) + .unwrap(); + } + + let unrelated_counts = |store: &SqliteWorkspaceStore| { + store + .with_conn(|connection| { + let mut tables = connection.prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + )?; + let names = tables + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + drop(tables); + let mut counts = std::collections::BTreeMap::new(); + for name in names { + if matches!( + name.as_str(), + "workspace_memory_documents" + | "memory_staging_records" + | "memory_staging_resolutions" + ) { + continue; + } + let quoted = name.replace('"', "\"\""); + let count = connection.query_row( + &format!("SELECT COUNT(*) FROM \"{quoted}\""), + [], + |row| row.get::<_, i64>(0), + )?; + counts.insert(name, count); + } + Ok(counts) + }) + .unwrap() + }; + let before = unrelated_counts(&store); + assert_eq!( + store + .get_workspace_memory_settings("local-dev") + .unwrap() + .language, + "English" + ); + + // A failure after resolution deletion must roll the entire operation back. + store + .with_conn(|connection| { + connection.execute_batch( + "CREATE TRIGGER fail_legacy_memory_reset + BEFORE DELETE ON memory_staging_records + WHEN OLD.workspace_id = 'local-dev' + BEGIN SELECT RAISE(ABORT, 'reset failure fixture'); END;", + )?; + Ok(()) + }) + .unwrap(); + assert!(store.reset_legacy_memory("local-dev").is_err()); + assert!(store.get_memory_document("local-dev").unwrap().is_some()); + assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 1); + assert_eq!( + store + .list_memory_staging_resolutions("local-dev", 10) + .unwrap() + .len(), + 1 + ); + store + .with_conn(|connection| { + connection.execute_batch("DROP TRIGGER fail_legacy_memory_reset")?; + Ok(()) + }) + .unwrap(); + + assert_eq!( + store.reset_legacy_memory("local-dev").unwrap(), + LegacyMemoryResetCounts { + documents: 1, + staging_records: 1, + staging_resolutions: 1, + } + ); + assert_eq!( + store.reset_legacy_memory("local-dev").unwrap(), + LegacyMemoryResetCounts::default() + ); + assert!(store.get_memory_document("local-dev").unwrap().is_none()); + assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 0); + assert!( + store + .list_memory_staging_resolutions("local-dev", 10) + .unwrap() + .is_empty() + ); + + assert!( + store + .get_memory_document("other-workspace") + .unwrap() + .is_some() + ); + assert_eq!( + store + .count_memory_staging_records("other-workspace") + .unwrap(), + 1 + ); + assert_eq!( + store + .list_memory_staging_resolutions("other-workspace", 10) + .unwrap() + .len(), + 1 + ); + assert_eq!(unrelated_counts(&store), before); + assert_eq!( + store + .get_workspace_memory_settings("local-dev") + .unwrap() + .language, + "English" + ); + } + #[tokio::test] async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 767f6bef4..467ddc862 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -141,6 +141,12 @@ pub struct SubjectRecord { pub updated_at: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectPage { + pub items: Vec, + pub has_more: bool, +} + /// Immutable host-recorded history that attributes one durable Session to the /// subject and Worker that produced it. This is archival provenance only: it is /// deliberately not a current-Worker link or execution ownership record. @@ -1085,6 +1091,35 @@ impl SubjektivStore { }) } + /// Lists one deterministic bounded page of subjects. Ordering uses immutable + /// creation time with the host-issued subject id as a total-order tie breaker. + pub fn list_subjects(&self, limit: usize) -> Result { + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject list limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let mut statement = connection.prepare( + "SELECT record_json FROM subjects + ORDER BY created_at ASC, subject_id ASC + LIMIT ?1", + )?; + let rows = statement + .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; + let mut items = Vec::new(); + for row in rows { + items.push(parse_subject(&row?)?); + } + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectPage { items, has_more }) + }) + } + /// Subject retirement is distinct from stopping any current Worker. It does /// not edit or delete Memory and cannot be reversed by this baseline schema. pub fn retire_subject(&self, subject_id: &str) -> Result { @@ -3506,6 +3541,155 @@ mod tests { (manager, workspace, store) } + #[test] + fn subject_listing_is_deterministic_bounded_and_read_only() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let mut expected = vec![ + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + ]; + expected.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let page = store.list_subjects(2).unwrap(); + assert_eq!(page.items, expected[..2]); + assert!(page.has_more); + assert!(store.list_subjects(0).is_err()); + assert!(store.list_subjects(101).is_err()); + + assert!(store.subject("missing-subject").unwrap().is_none()); + let complete = store.list_subjects(3).unwrap(); + assert_eq!(complete.items, expected); + assert!(!complete.has_more); + } + + #[test] + fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let session = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T09:00:00.000Z", + ); + + let (first_candidate, stored_session) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + session.clone(), + ) + .unwrap(); + assert_eq!(stored_session, session); + assert_eq!( + first_candidate.source_refs[0].session_id.as_deref(), + Some("session-1") + ); + + let create_request = CandidateDecisionRequest { + request_id: "decision-create-1".into(), + candidate_id: first_candidate.id.clone(), + reason: "adopt the attributed candidate".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Create, + draft: draft( + "Keep the first confirmed rule", + "initial attributed decision", + ), + }, + }; + let created = store + .decide_candidate(&subject.id, create_request.clone()) + .unwrap(); + let first_revision = created.memory.unwrap(); + assert_eq!(first_revision.revision, 1); + assert_eq!( + first_revision.source_candidate_ids, + [first_candidate.id.clone()] + ); + assert_eq!(created.resolution.affected_memory.len(), 1); + assert_eq!(created.resolution.affected_memory[0].revision, 1); + + // Exact decision retries return the original receipt rather than creating + // a second revision or resolution. + let replayed = store.decide_candidate(&subject.id, create_request).unwrap(); + assert_eq!(replayed.store_revision, created.store_revision); + assert_eq!(replayed.memory.as_ref().unwrap().revision, 1); + + let (second_candidate, _) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-2", "workspace-a"), + session, + ) + .unwrap(); + let revised = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-revise-2".into(), + candidate_id: second_candidate.id.clone(), + reason: "apply the attributed correction".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: first_revision.id.clone(), + expected_revision: 1, + }, + draft: draft("Keep the corrected confirmed rule", "attributed correction"), + }, + }, + ) + .unwrap(); + let second_revision = revised.memory.unwrap(); + assert_eq!(second_revision.revision, 2); + assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]); + + let historical = store + .scoped_memory_revision(&subject.id, &first_revision.id, 1) + .unwrap() + .unwrap(); + assert_eq!(historical.source_candidate_ids, [first_candidate.id]); + assert_eq!( + store + .session_attribution("session-1") + .unwrap() + .unwrap() + .subject_id, + subject.id + ); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.materials.len(), 1); + assert_eq!(generation.materials[0].memory_id, second_revision.id); + assert_eq!(generation.materials[0].revision, 2); + let expected_ref = MemoryRevisionRef { + memory_id: second_revision.id.clone(), + revision: second_revision.revision, + }; + let published = store + .publish_surface_generation( + &subject.id, + &generation.id, + vec![SurfacePoint { + body_md: "- Keep the corrected confirmed rule".into(), + memory_refs: vec![expected_ref.clone()], + }], + ) + .unwrap(); + assert_eq!(published.memory_refs, [expected_ref.clone()]); + assert_eq!(published.built_from_store_revision, revised.store_revision); + + let resident = store.resident_surface(&subject.id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Ready); + assert_eq!(resident.snapshot.unwrap().memory_refs, [expected_ref]); + } + #[test] fn versioned_json_roundtrips_and_rejects_invalid_shapes() { let staging = candidate("subject-1", "candidate-1", "workspace-a"); diff --git a/docs/README.md b/docs/README.md index c6a863e0d..00c0e123f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority. 10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection. -11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. +11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — deprecated single-document Workspace Memory authority and its separation from subject-scoped subjektiv Memory. 12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。 @@ -30,6 +30,9 @@ It is not a dumping ground for external research, old plans, API inventories, or 22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. 23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。 +25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、revision、candidate consolidation境界。 +26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。 +27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。 ## What belongs here diff --git a/docs/design/memory-knowledge.md b/docs/design/memory-knowledge.md index fbf2726d4..88d6e7842 100644 --- a/docs/design/memory-knowledge.md +++ b/docs/design/memory-knowledge.md @@ -1,17 +1,23 @@ -# Workspace Memory authority +# Legacy Workspace Memory authority -Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths. +The single-document Workspace Memory product is a **legacy/deprecated** compatibility surface. Its document, staging candidates, and staging resolutions are durable Workspace context owned by the Workspace Server control-plane database. It is separate from subject-scoped subjektiv Memory, whose persistence authority is the Workspace's Server-managed `subjektiv` Feature database. -The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations. +Legacy Runtime Workers query and mutate the old document through the established typed `/api/w/{workspace_id}/memory...` operations and `MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and legacy `MemoryStaging*` tools. While those surfaces remain available, their existing inputs keep their existing Workspace-document meaning. They must not be silently redirected to subjektiv, assigned an inferred subject, or reused with a new interpretation. + +Worker-side `memory` code owns shared schemas and transport types, not persistence. Legacy `AppendAudit` is acknowledgement-only and has no dedicated durable Memory-audit rows. General Session logs and diagnostics remain independently durable under their own retention policies. Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command. -Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts. +Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only Session records for exact current facts. + +## subjektiv cutover and reset + +Enabling `feature.subjektiv` does not migrate or erase legacy rows. The only supported destructive boundary is the explicit, authorized legacy reset API described by the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). It atomically deletes the old document, staging candidates, and staging resolutions while retaining Workspace Memory settings, Sessions and diagnostics, Tickets, Knowledge, Skills, the subjektiv Feature database, and unrelated Workspace data. The reset is operator-triggered and idempotent; it is never a startup migration or Profile side effect. ## Upgrade guidance -Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification. +Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the intended Workspace-backed product surface. Do not assume the legacy control-plane reset imports repository-local or legacy Workspace data into subjektiv. Stale files may be archived or deleted only after verification. ## Preserved filesystem boundaries -This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority. +Repository-local Memory removal and the control-plane legacy reset do not affect repository checkouts, Git metadata, Runtime-owned Workdirs, Session logs or artifacts, Server data directories, XDG client/server configuration, or the subjektiv Feature database. They remove only their explicitly named legacy authority. diff --git a/docs/design/overview.md b/docs/design/overview.md index cb54d15f9..5fdb66d45 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d - `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend. - `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract. - `tools` implements built-in tools with bounded output and policy-aware execution. -- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane. +- `memory` owns shared legacy Memory transport plus extraction/evidence schemas, not persistence. Legacy single-document Memory lives in the Workspace Server control-plane database; subject-scoped Memory lives in the Workspace's Server-managed `subjektiv` Feature database. - `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files. - `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state. diff --git a/docs/design/server-feature-storage.md b/docs/design/server-feature-storage.md index 8572431ba..8a3d3f36b 100644 --- a/docs/design/server-feature-storage.md +++ b/docs/design/server-feature-storage.md @@ -1,6 +1,6 @@ # Server-managed Feature storage -`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. +`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. Subject-scoped Memory is persisted in the Workspace's `subjektiv` Feature database through this boundary; legacy single-document Workspace Memory remains in the control-plane database and is not owned, imported, or reset by `FeatureStorage`. ## Ownership boundary @@ -126,7 +126,7 @@ Not registering/opening a disabled Feature creates nothing. Disabling a previous `WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed. -Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. +Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. In particular, the [subjektiv product cutover](../development/subjektiv-product-cutover.md) stops legacy and subjektiv activity before pairing a control-plane backup with the subjektiv Feature backup; the explicit legacy reset never deletes or recreates the Feature database. `restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API. diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index bd0c77815..f9b1c2a00 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -1,8 +1,13 @@ # subjektiv subject Memory store -T-667 introduces a trusted Server-side `subjektiv` repository in -`yoi_workspace_server::subjektiv`. It is a domain store, not a Worker tool or a -cutover of the existing Workspace Memory authority. +The trusted Server-side `subjektiv` repository in +`yoi_workspace_server::subjektiv` is the persistence authority for subject-scoped +Memory. It is a domain store, not a Worker tool. Legacy single-document Workspace +Memory remains a separate, deprecated control-plane authority until an operator +performs the explicit reset defined by the +[subjektiv product cutover runbook](../development/subjektiv-product-cutover.md); +legacy rows are never imported, dual-written, or silently reinterpreted as subject +Memory. ## Ownership and scope @@ -85,8 +90,9 @@ immutable. A resolution stores both the resolution JSON and the exact staged JSO bytes that were resolved. Aggregate seal rows prevent evidence, derivations, resolution targets, or snapshot references from being appended after their parent JSON is finalized. Foreign keys plus retention triggers prevent dangling -candidate, derivation, resolution-target, and snapshot references. There is no -record deletion API in this baseline. +candidate, derivation, resolution-target, and snapshot references. Ordinary +subject operations expose no record deletion API; the legacy reset contract does +not touch this Feature database. Search columns and JSON projections are written together by the typed repository. Raw SQL remains private trusted implementation detail. Existing Workspace Memory @@ -280,3 +286,34 @@ proposal unresolved/conflicted. It must not parse target information from the claim, retarget to the latest revision, or revive a retracted Memory. Valid transitions at staging are revise while active/resolved, active→resolved, active/resolved→retracted, and resolved→active. Retraction remains terminal. + +## Candidate consolidation and corrections (T-670) + +The Backend starts a restricted `builtin:subjektiv-memory-consolidation` Worker +for exactly one authenticated subject. The Host binds the subject; model-visible +inputs cannot select a Workspace, subject, Runtime, Worker, or Session. Its tools +list/read pending immutable candidates, query/read confirmed revisions, and make +one candidate decision. Candidate text is evidence rather than authority. + +Every decision carries a stable request ID. An exact retry returns the committed +receipt, while reuse with different input is a conflict. Applying a candidate +atomically writes one new confirmed revision, its exact candidate and derivation +edges, the immutable resolution and affected revision references, and advances +the subject store revision. A non-applied decision explicitly records +`discarded`, `invalid`, `duplicate`, or `already_covered`; an empty model response, +transport failure, or aborted consolidation records no disposition and leaves the +candidate pending. A successful applied response is the only evidence that +confirmed Memory changed. + +A typed revision proposal must be applied to its exact `memory_id` and +`expected_revision`, preserving its intent and change reason. The transaction +rechecks the current revision and state transition. It never rebases or retargets +a stale proposal, revives retracted Memory, or extracts a target from claim prose. +Correction/refinement of the same experience appends under the same Memory ID; +a corrected experience after terminal retraction receives a new Memory ID and may +cite the retracted fixed revision as a derivation. Surface generation is a +separate post-consolidation lifecycle: its failure cannot roll back a committed +candidate decision. + +For the product activation, legacy reset, and operator recovery boundary, follow +the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). diff --git a/docs/design/subjektiv-surface.md b/docs/design/subjektiv-surface.md index c76050af8..76f957018 100644 --- a/docs/design/subjektiv-surface.md +++ b/docs/design/subjektiv-surface.md @@ -22,7 +22,34 @@ Every editor point carries one or more exact `{memory_id, revision}` references. Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition. -Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. The resident Backend contract transports typed `ready`, `un-generated`, `failed`, and `stale` availability separately from optional Markdown, so a valid ready-empty snapshot cannot be conflated with omission or request failure. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Runtime-owned new Workers install Features before durably materializing the initial session head, ensuring the current contribution is present in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface, an explicit ready-empty state, or a no-current-surface tombstone; subsequent turns in the same restored process do not append it again. +Resident injection uses five distinct product states: + +- `ready`: inject the non-empty snapshot only when its `built_from_store_revision` + equals the subject's exact current store revision; +- `ready-empty`: a successful current generation selected no Markdown; inject no + prose, but preserve the explicit successful-empty state; +- `ungenerated`: no successful current generation exists; inject nothing; +- `stale`: confirmed Memory changed after publication (or the snapshot predates + this generation policy); inject nothing until regeneration succeeds; +- `failed`: the generation attempt failed; inject nothing and preserve the typed + failure for diagnostics/retry rather than representing it as empty Memory. + +The resident Backend transports availability separately from optional Markdown. +In storage, `ready-empty` is a current `ready` snapshot with empty Markdown; it is +never conflated with `ungenerated`, `stale`, `failed`, request failure, or +omission. Publication is append-only: generation inputs/runs, failures, snapshots, +and exact references remain immutable history, while current state points at the +applicable result. + +Runtime-owned new Workers install Features before durably materializing the +initial Session head, so `ready` content or the explicit `ready-empty` result is +represented at initial prompt construction. Restored Workers preserve their +persisted prompt and all prior history verbatim. They append one durable +restore-boundary system item that supersedes earlier resident summaries with the +latest `ready` surface, an explicit `ready-empty` state, or a +no-current-surface tombstone for `ungenerated`, `stale`, or `failed`; subsequent +turns in that restored process do not append the boundary again. No regeneration, +correction, failure, or cutover rewrites a committed Session entry. ## Semantic fixture and verification @@ -45,4 +72,4 @@ A valid review fixture must: - cite only exact refs supplied with those materials; - distinguish mechanical citation validation from a human/model semantic review of the wording. -The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/un-generated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. +The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/ungenerated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior. diff --git a/docs/development/subjektiv-product-cutover.md b/docs/development/subjektiv-product-cutover.md new file mode 100644 index 000000000..d2640f6cd --- /dev/null +++ b/docs/development/subjektiv-product-cutover.md @@ -0,0 +1,98 @@ +# subjektiv product cutover runbook + +This runbook defines the product and operator boundary for moving a Workspace from legacy single-document Memory to subject-scoped subjektiv Memory. The cutover changes which product path is active; it does not reinterpret legacy tool or API inputs, migrate legacy records into subjektiv, or make historical Worker attribution into current authority. + +T-672 documents and validates the contract only. It does **not** authorize or perform a reset in any real environment. + +## Authorities and persistence + +Two independent Memory systems exist during the transition: + +| Product path | Persistence owner | Durable content | Status | +| --- | --- | --- | --- | +| Legacy Workspace Memory | Workspace Server control-plane database | one Workspace document, staging candidates, and staging resolutions | legacy/deprecated; retained only for compatibility and explicit reset | +| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory revisions, Session attribution, surface generation records, and immutable surface snapshots | current product path | + +The shared `memory` crate and Worker Features do not own either database. `FeatureStorage` owns the subjektiv SQLite lifecycle (path, connection, migration, backup/restore, shutdown, and Workspace deletion), while the subjektiv repository owns its schema and domain transactions. The legacy control-plane tables are not imported or dual-written into subjektiv. + +Every subject operation is bounded first by the authenticated Workspace and then by an explicitly authorized subject. A subject ID is a selector, not a credential. The Host derives subject scope from authenticated Runtime/Worker source proof and current ownership of the opaque `subjektiv:` Worker key; model-visible requests do not supply or expand that authority. + +The current Worker remains Yoi's keyed-singleton responsibility. The subjektiv database records immutable historical Worker and Session attribution, but it contains no second `current_worker_id` mapping and historical attribution is never used to choose or authorize the current Worker. Stopping or replacing a Worker does not retire its subject, and retiring a subject does not stop a Worker. + +Workspace Memory settings remain Workspace-owned configuration. When `feature.subjektiv` is enabled, the Backend binds the trusted settings snapshot used for extraction language; the snapshot is not subject identity and cannot be authored by a Profile, Browser, or model. + +## Session, Memory, and correction semantics + +- **Session attribution and recall:** only a committed primary-Worker Session with Host-recorded subject attribution enters subject Session discovery. Attribution is immutable and idempotent. Recall reads the retained or committed-archive Session projection under current Workspace, subject, observation, and retention authorization; it does not copy Session bodies into subjektiv, infer attribution for older Sessions, restore a Worker, or turn recalled tool output into `HumanInput`. +- **`SubjektivMemoryRemember`:** stages a new candidate; it does not directly create confirmed Memory. Explicit committed evidence is resolved against the Session capture. A non-preference request without an entry is staged only after its tool call commits, using the committed call as model-origin evidence. An uncommitted operation creates no candidate. +- **`SubjektivMemoryProposeRevision`:** stages a candidate with typed target metadata: exact Memory ID, expected revision, intent, and change reason. It does not mutate the target or silently retarget to a newer revision. +- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed revision, exact provenance and affected revision references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision. +- **Corrections:** refinement or correction of the same experience appends the next revision under the same Memory ID using an expected-revision check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed revision. Old revisions, candidates, resolutions, provenance, and surface snapshots remain immutable history. + +Confirmed Memory is useful subject context, not authorization and not exact implementation authority. Tickets, Objectives, repository state, Git history, and append-only Session records remain the exact sources for implementation state. + +## Resident surface and history + +A subject surface is a bounded rendering of current active confirmed revisions, not another Memory authority. The injection contract is: + +- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current store revision; +- **ready-empty:** explicitly record that a successful current generation produced no Markdown and inject no Memory prose; do not conflate this with missing or failed generation; +- **ungenerated:** inject nothing and report no current surface; +- **stale:** inject nothing until regeneration publishes a snapshot for the current store revision; +- **failed:** inject nothing; retain the failure for diagnosis and retry rather than presenting it as an empty Memory. + +A new Worker receives the current ready or ready-empty result in its initial durable prompt construction. A restored Worker does not rewrite its persisted prompt or prior Session history. It appends one durable restore-boundary system item that supersedes prior resident summaries with the latest ready surface, explicit ready-empty state, or a no-current-surface tombstone. Later turns in the same restored process do not append the boundary again. This preserves append-only history: no stale summary, failed generation, correction, or cutover operation edits previously committed Session entries or immutable subjektiv records. + +## Legacy/deprecated compatibility boundary + +The existing legacy tools (`MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and the legacy `MemoryStaging*` operations) and `/api/w/{workspace_id}/memory...` APIs retain their established Workspace-document meanings while they exist. They are legacy/deprecated surfaces, not aliases for subject Memory. Do not silently route an old request to subjektiv, infer a subject from old input, or change an old input field's meaning. + +New product work uses the explicit subject/session/confirmed-Memory/revision/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema. + +## Explicit legacy reset contract + +The legacy reset is a destructive, operator-triggered API operation scoped to one authorized Workspace. Startup, Profile resolution, enabling `feature.subjektiv`, Worker restore, consolidation, and ordinary reads must never trigger it implicitly. + +In one control-plane database transaction, the reset deletes all of the following legacy rows for the Workspace: + +1. the legacy Memory document; +2. all legacy Memory staging candidates; +3. all legacy Memory staging resolutions. + +The operation is all-or-nothing. A failure before commit leaves all three legacy sets unchanged. A retry after success is idempotent: absence of those rows is success and must not delete anything outside the legacy set. + +Legacy `AppendAudit` was acknowledgement-only and created no dedicated durable Memory-audit rows, so there is no fourth legacy audit table to erase. General Session logs and diagnostics remain retained under their own policies. + +The reset must retain: + +- Workspace Memory settings and their revision; +- Sessions, Session logs, archives, and diagnostics; +- Tickets, Objectives, and their audit/history; +- Knowledge and Skills; +- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed revisions, attribution, generation runs, failures, and surface snapshots; +- all other new-product and unrelated Workspace data. + +The reset is not a subject deletion API, a Workspace deletion API, a settings reset, a retention override, or a data migration. + +## Operator procedure + +Perform the cutover in this order: + +1. **Stop legacy activity.** Disable legacy `feature.memory` Profiles and automatic extraction/consolidation, stop or drain old Workers, and block Browser/API clients from writing `/memory` routes. Wait for in-flight legacy writes to finish. Do not run legacy and new automatic extraction for the same Worker. +2. **Back up while writers are stopped.** Capture the control-plane database containing legacy Memory and a `FeatureStorage` backup containing subjektiv. Record the Workspace and backup identities and verify the backup artifacts. Feature backup is internally consistent, but there is no cross-database snapshot instant; stopping both writer sets is what makes the pair a usable cutover checkpoint. +3. **Trigger reset explicitly.** An authenticated operator invokes the legacy reset API for the intended Workspace. Do not automate this as a startup migration or infer consent from `feature.subjektiv` enablement. +4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed revisions, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:` singleton and has only the intended new tools. +5. **Resume only the new path.** Start subject Workers with `feature.subjektiv`, keep legacy write surfaces disabled, and monitor typed surface/Session diagnostics. Do not treat an ungenerated, stale, or failed surface as ready-empty. + +### Failure and recovery + +- If reset returns a definite failure, the transaction rollback means legacy document, staging, and resolutions remain together. Correct the cause and rerun the same explicit operation. +- If communication is lost and the result is ambiguous, keep legacy writers stopped, inspect all three legacy sets, and rerun. Idempotency makes a committed first attempt safe to repeat and atomicity prevents a valid partial state. +- If verification of retained data or the new path fails, keep both write paths stopped. Restore the coordinated pre-reset backups according to the normal control-plane and `FeatureStorage` restore procedures, verify them, and only then resume. Never "recover" by deleting or recreating the subjektiv database. +- Preserve Session logs and diagnostics throughout; they are recovery evidence, not reset targets. + +## Validation boundary + +The validation supporting this cutover proves substrate-level store shutdown/reopen behavior and restoration of a Worker object with its persisted history. It does **not** claim a restart of the Server or Runtime process, behavior during communication loss, real-model consolidation or surface quality, or a full-process end-to-end cutover. Those require separate operational validation before a production reset. + +No real environment reset is performed or authorized by T-672. diff --git a/docs/development/ui-ux/product-ux.md b/docs/development/ui-ux/product-ux.md index 3420a646b..5342eb19c 100644 --- a/docs/development/ui-ux/product-ux.md +++ b/docs/development/ui-ux/product-ux.md @@ -57,8 +57,11 @@ Workspaceを選ぶ前のscopeを扱う。 2. Objectives 3. Merge Requests 4. Memory - - Document - - Staging + - Subjects + - Surface + - Committed Memory + - Revisions + - Sources 5. Workers ### Settings @@ -122,6 +125,16 @@ Workspace pageは、次のいずれかを基本形とする。 - narrow viewportでは一列にする。 - form内でfilled actionにするのはsave actionだけとする。 +### Memory + +- Memoryの入口はWorkspace全体のlegacy `Document` / `Staging`ではなく、明示的に選択したsubjectとする。 +- subject detailではcurrent Workerをkeyed-singleton projectionとして表示し、historical Worker/Session attributionをcurrent authorityとして扱わない。 +- `Surface`はresident contextのcurrent stateを表示する。`ready`、`ready-empty`、`ungenerated`、`stale`、`failed`を区別し、空表示やrequest failureへまとめない。 +- `Committed Memory`はcurrent confirmed revisionの一覧とdetailを持つ。revision historyはappend-only viewとして分離し、過去revisionをcurrentへ暗黙fallbackしない。 +- `Sources`は候補、resolution、exact Memory revision refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。 +- legacy `/memory` の`Document` / `Staging` viewはdeprecated compatibility surfaceとして明示し、新しいsubject viewへ同じinputを別の意味で転送しない。 +- Markdown本文とdetail presentationはT-661の共有rendererを再利用する。Memory route独自のrenderer、application shell、Header、Sidebarを再実装せず、既存shellのmain contentとnested navigationを保つ。 + ### Worker Console - transcriptをprimary surfaceとする。 diff --git a/docs/manifest.toml b/docs/manifest.toml index 884866ef7..bc5ac76d3 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -235,32 +235,40 @@ permission = "write" # # ref = "anthropic/claude-haiku-4-5" -# ===== [feature.memory] ====================================================== -# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では -# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを -# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが -# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。 -# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる -# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。 +# ===== [feature.memory] (legacy/deprecated) ================================ +# Legacy single-document Workspace Memory compatibility設定。新規product pathは +# `[feature.subjektiv]` を使う。旧`MemoryQuery` / `MemoryReadDocument` / +# `MemoryUpdateDocument` / `MemoryStaging*` toolsと`/memory` APIのinput意味は +# 変更せず、subject Memoryへ暗黙転送しない。 # -# [feature.memory.profile] +# resolved Worker ManifestではProfile由来の設定を`profile`に、Backend由来の +# Workspace設定snapshotを`workspace_settings`に分離して保存する。source TOMLの +# `[feature.memory]` fieldsはresolve後に`profile`へ入り、`workspace_settings`は +# Backendだけがbindする信頼済み入力で、Profile・Browser・ +# model入力から指定できない。source `enabled = false`(resolvedでは +# `profile.enabled = false`)の場合、legacy Memory tools、 +# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。 +# `[feature.subjektiv]`の有効化やWorker restoreはlegacy dataをreset/migrateしない。 +# legacy resetは認証済みoperatorが明示APIで別途実行する。 +# +# [feature.memory] # enabled = true # staging_tools = false # -# [feature.memory.profile.resident] +# [feature.memory.resident] # inject_summary = true # -# [feature.memory.profile.extraction] +# [feature.memory.extraction] # enabled = true # reasoning = "medium" # threshold = 30000 # worker_max_turns = 8 # # # modelは任意。省略時はmain modelをcloneする。 -# # [feature.memory.profile.extraction.model] +# # [feature.memory.extraction.model] # # ref = "codex-oauth/gpt-5.6-luna" # -# [feature.memory.profile.consolidation] +# [feature.memory.consolidation] # request_enabled = true # # # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。 @@ -273,6 +281,42 @@ permission = "write" # policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。 +# ===== [feature.subjektiv] ================================================ +# Current subject-scoped Memory product。ProfileはFeature activationと自動extract +# policyだけを所有する。subject idはManifest fieldではない。Hostが認証済み +# Runtime/Worker sourceとcurrent `subjektiv:` keyed-singleton ownershipから +# operationごとにbindし、明示Workspace/subject authorizationを再評価する。 +# +# `enabled = true`はsubject tools、Session attribution/recall、confirmed Memory、 +# candidate consolidation、resident surface lifecycleを有効にする。legacy +# `[feature.memory]` dataのimport/reset/dual-writeは行わない。同じWorkerでlegacyと +# subjektivの自動extract destinationを同時に有効化する構成は拒否される。 +# +# [feature.subjektiv] +# enabled = true +# +# [feature.subjektiv.extraction] +# enabled = true +# reasoning = "medium" +# threshold = 50000 +# worker_max_turns = 8 +# +# # modelは任意。省略時はmain modelをcloneする。 +# # [feature.subjektiv.extraction.model] +# # ref = "codex-oauth/gpt-5.6-luna" +# +# # Backendがresolved Manifestへbindする。Profile・Browser・model入力では指定しない。 +# # language/settings revisionのtrusted snapshotであり、subject identityではない。 +# # [feature.subjektiv.workspace_settings] +# # workspace_id = "workspace-id" +# # settings_revision = 1 +# # language = "日本語" +# +# Surface selection/generation/injection、candidate consolidation eligibility、recall +# limitsはHost/Backend policyが所有し、通常Worker Manifestには含めない。current Worker +# authorityはYoi keyed singletonに残り、historical Session attributionを代用しない。 + + # ===== [skills] ============================================================= # 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。 # セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や diff --git a/openapi/server-api.json b/openapi/server-api.json index a024f5f80..37f741ddf 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2604,6 +2604,53 @@ ], "type": "object" }, + "LegacyMemoryResetRequest": { + "additionalProperties": false, + "description": "Explicit destructive reset request. The confirmation must exactly equal\n`RESET LEGACY MEMORY ` for the Workspace named in the route.", + "properties": { + "confirmation": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "confirmation" + ], + "type": "object" + }, + "LegacyMemoryResetResponse": { + "additionalProperties": false, + "properties": { + "documents_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_records_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_resolutions_deleted": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "documents_deleted", + "staging_records_deleted", + "staging_resolutions_deleted" + ], + "type": "object" + }, "LogoutResponse": { "additionalProperties": false, "properties": { @@ -8734,6 +8781,535 @@ ], "type": "object" }, + "SubjektivMemoryDetailQuery": { + "additionalProperties": false, + "properties": { + "byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "evidence_cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryEvidenceCandidate": { + "additionalProperties": false, + "properties": { + "candidate_id": { + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/StagingEvidence" + }, + "type": "array" + }, + "evidence_total": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "evidence_truncated": { + "type": "boolean" + }, + "source_refs": { + "items": { + "$ref": "#/components/schemas/SourceEvidenceRef" + }, + "type": "array" + }, + "source_refs_total": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_refs_truncated": { + "type": "boolean" + } + }, + "required": [ + "candidate_id", + "evidence", + "evidence_total", + "evidence_truncated", + "source_refs", + "source_refs_total", + "source_refs_truncated" + ], + "type": "object" + }, + "SubjektivMemoryListQuery": { + "additionalProperties": false, + "description": "Browser GET query for committed subject Memory. Enum lists use a bounded,\ncomma-separated scalar representation so URL-form encoding stays portable.", + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "kinds": { + "items": { + "$ref": "#/components/schemas/CandidateKind" + }, + "type": [ + "array", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "states": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryListRevisionsResponse": { + "additionalProperties": false, + "properties": { + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionItem" + }, + "type": "array" + }, + "memory_id": { + "type": "string" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "memory_id", + "current_revision", + "items", + "has_more" + ], + "type": "object" + }, + "SubjektivMemoryQueryItem": { + "additionalProperties": false, + "properties": { + "claim": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "revision", + "kind", + "state", + "claim", + "excerpt", + "updated_at" + ], + "type": "object" + }, + "SubjektivMemoryQueryResponse": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryQueryItem" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "has_more" + ], + "type": "object" + }, + "SubjektivMemoryReadResponse": { + "additionalProperties": false, + "properties": { + "body_byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "body_md": { + "description": "A line-bounded slice, never an implicit full-document claim.", + "type": "string" + }, + "body_next_byte_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "body_next_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "body_offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "body_truncated": { + "type": "boolean" + }, + "change_reason": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "derived_from": { + "description": "Derivation references on this evidence page.", + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + }, + "type": "array" + }, + "evidence_has_more": { + "type": "boolean" + }, + "evidence_next_cursor": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "memory_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "source_candidate_ids": { + "description": "Candidate ids on this evidence page. A compatible historical candidate\nmay repeat across pages while its nested anchors advance through the same\nevidence cursor. Corresponding bounded anchors retain the same order in\n`source_candidates`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "source_candidates": { + "items": { + "$ref": "#/components/schemas/SubjektivMemoryEvidenceCandidate" + }, + "type": "array" + }, + "staleness": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + }, + "why_useful": { + "type": "string" + } + }, + "required": [ + "memory_id", + "revision", + "current_revision", + "kind", + "state", + "claim", + "body_md", + "why_useful", + "change_reason", + "created_at", + "updated_at", + "body_offset", + "body_byte_offset", + "body_truncated", + "source_candidate_ids", + "source_candidates", + "derived_from", + "evidence_has_more" + ], + "type": "object" + }, + "SubjektivMemoryRevisionItem": { + "additionalProperties": false, + "properties": { + "change_reason": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/SubjektivMemoryState" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "revision", + "kind", + "state", + "claim", + "change_reason", + "updated_at" + ], + "type": "object" + }, + "SubjektivMemoryRevisionRef": { + "additionalProperties": false, + "properties": { + "memory_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "memory_id", + "revision" + ], + "type": "object" + }, + "SubjektivMemoryRevisionsQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivMemoryState": { + "description": "Current lifecycle state of one confirmed subject Memory revision.", + "enum": [ + "active", + "resolved", + "retracted" + ], + "type": "string" + }, + "SubjektivResidentSurfaceAvailability": { + "enum": [ + "ungenerated", + "stale", + "failed", + "ready" + ], + "type": "string" + }, + "SubjektivResidentSurfaceResponse": { + "additionalProperties": false, + "properties": { + "availability": { + "$ref": "#/components/schemas/SubjektivResidentSurfaceAvailability" + }, + "snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjektivResidentSurfaceSnapshot" + }, + { + "type": "null" + } + ] + }, + "subject_id": { + "type": "string" + } + }, + "required": [ + "subject_id", + "availability" + ], + "type": "object" + }, + "SubjektivResidentSurfaceSnapshot": { + "additionalProperties": false, + "properties": { + "body_md": { + "type": "string" + }, + "built_from_store_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "memory_refs": { + "default": [], + "items": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionRef" + }, + "type": "array" + }, + "snapshot_id": { + "type": "string" + } + }, + "required": [ + "snapshot_id", + "body_md", + "built_from_store_revision", + "created_at" + ], + "type": "object" + }, "SubjektivSubjectCreateRequest": { "additionalProperties": false, "properties": { @@ -8746,6 +9322,47 @@ ], "type": "object" }, + "SubjektivSubjectListQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SubjektivSubjectListResponse": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "limit", + "items", + "has_more" + ], + "type": "object" + }, "SubjektivSubjectResponse": { "additionalProperties": false, "properties": { @@ -14121,7 +14738,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0" + "x-yoi-source-digest": "sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3" }, "openapi": "3.1.0", "paths": { @@ -19140,6 +19757,103 @@ ] } }, + "/api/w/{workspace_id}/memory/reset": { + "post": { + "operationId": "legacy_memory_reset", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LegacyMemoryResetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LegacyMemoryResetResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/memory/staging": { "get": { "operationId": "memory_staging_list", @@ -28063,6 +28777,101 @@ } }, "/api/w/{workspace_id}/subjektiv/subjects": { + "get": { + "operationId": "subjektiv_subject_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, "post": { "operationId": "subjektiv_subject_create", "parameters": [ @@ -28266,6 +29075,357 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { + "get": { + "operationId": "subjektiv_memory_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryQueryResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}": { + "get": { + "operationId": "subjektiv_memory_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "memory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryDetailQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryReadResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions": { + "get": { + "operationId": "subjektiv_memory_revisions", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "memory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryRevisionsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivMemoryListRevisionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": { "post": { "operationId": "subjektiv_subject_retire", @@ -28373,6 +29533,103 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface": { + "get": { + "operationId": "subjektiv_resident_surface", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivResidentSurfaceResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker": { "post": { "operationId": "subjektiv_subject_worker_start", diff --git a/resources/profiles/backend-job.dcdl b/resources/profiles/backend-job.dcdl index 15c64d83c..8f2afe8d7 100644 --- a/resources/profiles/backend-job.dcdl +++ b/resources/profiles/backend-job.dcdl @@ -12,6 +12,7 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; memory = { enabled = false; }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = false; }; image = { enabled = false; }; sub_worker = { enabled = false; }; diff --git a/resources/profiles/base.dcdl b/resources/profiles/base.dcdl index 04e42783f..fd8f71a7e 100644 --- a/resources/profiles/base.dcdl +++ b/resources/profiles/base.dcdl @@ -24,16 +24,15 @@ compaction = { feature = { task = { enabled = true; }; - memory = { + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = true; - resident = { inject_summary = true; }; extraction = { enabled = true; model = { ref = "codex-oauth/gpt-5.6-luna"; }; reasoning = "medium"; threshold = 50000; }; - consolidation = { request_enabled = true; }; }; web = { enabled = true; }; image = { enabled = true; }; diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 5067ddca8..174e8d359 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -10,7 +10,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = true; }; flow = { enabled = true; }; diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index 43366d48d..bbce5930e 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = true; }; worker = { enabled = true; direct_spawn = false; }; diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index e12ca0a97..4ddfb0a5e 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -7,6 +7,7 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = true; }; image = { enabled = true; }; sub_worker = { enabled = true; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index 1778c9307..649b4e166 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/memory-consolidation.dcdl b/resources/profiles/memory-consolidation.dcdl index 86eb50670..4333bc931 100644 --- a/resources/profiles/memory-consolidation.dcdl +++ b/resources/profiles/memory-consolidation.dcdl @@ -7,7 +7,19 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; - memory = { enabled = true; staging_tools = true; }; + memory = { + enabled = true; + staging_tools = true; + resident = { inject_summary = true; }; + extraction = { + enabled = true; + model = { ref = "codex-oauth/gpt-5.6-luna"; }; + reasoning = "medium"; + threshold = 50000; + }; + consolidation = { request_enabled = true; }; + }; + subjektiv = { enabled = false; extraction = { enabled = false; }; }; web = { enabled = false; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index cb654b633..dee53280a 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -6,7 +6,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = true; direct_spawn = false; }; diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index 29ee93768..6b9769bca 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -10,7 +10,8 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { enabled = false; }; + subjektiv = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; diff --git a/resources/profiles/subjektiv-memory-consolidation.dcdl b/resources/profiles/subjektiv-memory-consolidation.dcdl index f7fbba83e..b3c9ec16d 100644 --- a/resources/profiles/subjektiv-memory-consolidation.dcdl +++ b/resources/profiles/subjektiv-memory-consolidation.dcdl @@ -8,14 +8,8 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; - memory = { - enabled = true; - staging_tools = true; - resident = { inject_summary = false; }; - extraction = { enabled = false; }; - consolidation = { request_enabled = false; }; - }; - subjektiv = { enabled = false; extraction = { enabled = false; }; }; + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = true; extraction = { enabled = false; }; }; web = { enabled = false; }; image = { enabled = false; }; sub_worker = { enabled = false; }; diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 89fa3d098..d97aaf8cd 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -11,23 +11,263 @@ if ( } const workspaceId = "memory-review"; +const representativeSubjectId = + "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; +const representativeMemoryId = + "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001"; +const derivedMemoryId = "memory-derived-source-0002"; +const emptySubjectId = "empty-subject"; +const staleSubjectId = "stale-subject"; +const failedSubjectId = "failed-subject"; +const ungeneratedSubjectId = "ungenerated-subject"; +const errorSubjectId = "error-subject"; + const paragraphs = Array.from( { length: sectionCount }, (_, index) => `## Section ${ index + 1 - }\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${ + }\n\nThis is representative resident Memory context with **important context**, an identifier \`memory-item-${ index + 1 }\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${ index + 1 }.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`, ).join("\n\n"); -const bodyMd = - `# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${ +const surfaceBodyMd = + `# Resident subject context\n\nThis generated surface is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${ "wide-value-".repeat(16) - } | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${ + } | stable | keep scrolling local |\n\n##### Deep surface heading\n\n###### Deepest surface heading\n\n\`\`\`rust\nfn example() { println!("${ "wide-code-".repeat(18) - }"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`; + }"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n\n\n${paragraphs}\n\n## Surface content complete`; + +const detailBodyMd = `# Committed Memory detail + +This immutable revision preserves its candidate evidence, source refs, and derivation refs. + +| Local table overflow | Value | +| --- | --- | +| ${"detail-wide-value-".repeat(14)} | stable | + +\`\`\`text +${"detail-wide-code-".repeat(18)} +\`\`\` + +[Safe evidence link](https://example.com/evidence) [Unsafe evidence link](javascript:alert(2)) + +`; + +const subjects = [ + subject(representativeSubjectId, "Release coordination", "active", 42), + subject(emptySubjectId, "Empty ready subject", "active", 0), + subject(staleSubjectId, "Stale surface subject", "active", 19), + subject(failedSubjectId, "Failed surface subject", "active", 8), + subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0), + subject(errorSubjectId, "Unavailable subject", "retired", 3), +]; + +function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { + return { + id, + role, + state, + store_revision: storeRevision, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + }; +} + +function memoriesFor(subjectId: string) { + if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; + if (subjectId === staleSubjectId) { + return [ + memorySummary("stale-memory-1", 2, "lesson", "resolved", "A resolved stale-surface lesson"), + ]; + } + if (subjectId === failedSubjectId) { + return [ + memorySummary( + "failed-memory-1", + 1, + "constraint", + "retracted", + "A retracted failed-surface constraint", + ), + ]; + } + return [ + memorySummary( + representativeMemoryId, + 3, + "decision", + "active", + "Keep subject Memory provenance typed and visible", + ), + memorySummary( + "memory-resolved-0002", + 2, + "lesson", + "resolved", + "The bounded reader must preserve continuations", + ), + memorySummary( + "memory-retracted-0003", + 1, + "working_assumption", + "retracted", + "T-672 UNIQUE END MARKER", + ), + ]; +} + +function memorySummary( + id: string, + revision: number, + kind: string, + state: string, + claim: string, +) { + return { + id, + revision, + kind, + state, + claim, + excerpt: `Read-only ${state} Memory with exact revision and provenance metadata.`, + updated_at: "2026-01-02T03:04:05Z", + }; +} + +function surfaceFor(subjectId: string) { + if (subjectId === representativeSubjectId) { + return { + subject_id: subjectId, + availability: "ready", + snapshot: { + snapshot_id: "surface-snapshot-0042", + body_md: surfaceBodyMd, + memory_refs: [ + { memory_id: representativeMemoryId, revision: 3 }, + { memory_id: derivedMemoryId, revision: 4 }, + ], + built_from_store_revision: 42, + created_at: "2026-01-02T03:04:05Z", + }, + }; + } + if (subjectId === emptySubjectId) { + return { + subject_id: subjectId, + availability: "ready", + snapshot: { + snapshot_id: "surface-snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-01-02T03:04:05Z", + }, + }; + } + if (subjectId === staleSubjectId) return { subject_id: subjectId, availability: "stale" }; + if (subjectId === failedSubjectId) return { subject_id: subjectId, availability: "failed" }; + return { subject_id: subjectId, availability: "ungenerated" }; +} + +function memoryDetail(memoryId: string, requestedRevision: number | null) { + const revision = requestedRevision && requestedRevision <= 3 ? requestedRevision : 3; + const states = { 1: "retracted", 2: "resolved", 3: "active" } as const; + return { + memory_id: memoryId, + revision, + current_revision: 3, + kind: "decision", + state: states[revision as keyof typeof states], + claim: revision === 3 + ? "Keep subject Memory provenance typed and visible" + : `Historical provenance decision, revision ${revision}`, + body_md: detailBodyMd, + why_useful: + "Future readers can audit why a committed Memory exists without exposing unbounded session content.", + staleness: null, + change_reason: revision === 3 + ? "Clarified the durable read contract." + : "Immutable historical revision.", + created_at: "2026-01-01T00:00:00Z", + updated_at: `2026-01-0${revision}T03:04:05Z`, + body_offset: 0, + body_byte_offset: 0, + body_truncated: false, + source_candidate_ids: ["candidate-release-decision-0001"], + source_candidates: [{ + candidate_id: "candidate-release-decision-0001", + evidence: [{ + id: "evidence-message-0001", + kind: "message", + entry_range: [12, 14], + origin: { + kind: "flow_instruction", + workspace_id: workspaceId, + runtime_id: "runtime-fixture", + worker_id: "worker-fixture", + flow_selector: "builtin:coder-review", + flow_definition_id: "flow-definition-fixture", + flow_definition_revision: 7, + }, + excerpt: "Use normal explicit subject routes and preserve provenance.", + summary: "The product contract was explicitly requested.", + }], + evidence_total: 1, + evidence_truncated: false, + source_refs: [{ + session_id: "session-fixture-0001", + segment_id: "segment-fixture-0001", + entry_range: [12, 14], + evidence_id: "evidence-message-0001", + origin: { kind: "human_input", account_id: "account-fixture" }, + evidence_kind: "message", + label: "T-672 product direction", + summary: "Bounded source reference retained with the committed revision.", + }], + source_refs_total: 1, + source_refs_truncated: false, + }], + derived_from: [{ memory_id: derivedMemoryId, revision: 4 }], + evidence_has_more: false, + }; +} + +function revisions(memoryId: string) { + return { + memory_id: memoryId, + current_revision: 3, + items: [ + { + revision: 3, + kind: "decision", + state: "active", + claim: "Keep subject Memory provenance typed and visible", + change_reason: "Clarified the durable read contract.", + updated_at: "2026-01-03T03:04:05Z", + }, + { + revision: 2, + kind: "decision", + state: "resolved", + claim: "Historical provenance decision, revision 2", + change_reason: "Resolved after endpoint integration.", + updated_at: "2026-01-02T03:04:05Z", + }, + { + revision: 1, + kind: "decision", + state: "retracted", + claim: "Historical provenance decision, revision 1", + change_reason: "Retracted the legacy shape.", + updated_at: "2026-01-01T03:04:05Z", + }, + ], + has_more: false, + }; +} const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { @@ -110,15 +350,34 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (url.pathname === `/api/w/${workspaceId}/working-directories`) { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); } - if (url.pathname === `/api/w/${workspaceId}/memory`) { - return json({ - body_md: bodyMd, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: new TextEncoder().encode(bodyMd).byteLength, - record_source: "sqlite_workspace_authority.memory_document", - }); + + const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; + if (url.pathname === subjectsPath) { + return json({ limit: 100, items: subjects, has_more: false }); } + if (url.pathname.startsWith(`${subjectsPath}/`)) { + const suffix = url.pathname.slice(subjectsPath.length + 1); + const parts = suffix.split("/").map(decodeURIComponent); + const subjectId = parts[0]; + if (subjectId === errorSubjectId) return json({}, 503); + const foundSubject = subjects.find((item) => item.id === subjectId); + if (!foundSubject) return json({}, 404); + + if (parts.length === 1) return json(foundSubject); + if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); + if (parts.length === 2 && parts[1] === "memories") { + return json({ items: memoriesFor(subjectId), has_more: false }); + } + if (parts.length >= 3 && parts[1] === "memories") { + const memoryId = parts[2]; + if (parts.length === 4 && parts[3] === "revisions") return json(revisions(memoryId)); + if (parts.length === 3) { + const requested = url.searchParams.get("revision"); + return json(memoryDetail(memoryId, requested === null ? null : Number(requested))); + } + } + } + if ( url.pathname === `/api/w/${workspaceId}/protocol/ws` && request.headers.get("upgrade") === "websocket" diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 54c8d403f..873e7d0a7 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -9,6 +9,10 @@ const fixtureServer = join( "tools/web-ux/browser-tests/memory_document_fixture_server.ts", ); const workspaceId = "memory-review"; +const subjectId = + "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; +const memoryId = + "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001"; async function freePort(): Promise { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); @@ -41,8 +45,8 @@ async function markerIsInsideMain(page: Page): Promise { return await page.evaluate(() => { const document = (globalThis as any).document; const main = document.querySelector("main"); - const marker = [...document.querySelectorAll("h2")].find((heading) => - heading.textContent === "T-661 UNIQUE END MARKER" + const marker = [...document.querySelectorAll("h3")].find((heading) => + heading.textContent === "T-672 UNIQUE END MARKER" ); if (!main || !marker) return false; const mainRect = main.getBoundingClientRect(); @@ -51,31 +55,86 @@ async function markerIsInsideMain(page: Page): Promise { }); } -async function openMemoryPage( +async function assertNoPageWideOverflow(page: Page): Promise { + assertEquals( + await page.evaluate(() => { + const document = (globalThis as any).document; + return document.documentElement.scrollWidth === document.documentElement.clientWidth; + }), + true, + ); +} + +async function assertMainOwnsVerticalScroll(page: Page): Promise { + const nested = await page.locator("main").evaluate((main) => + [...main.querySelectorAll("*")] + .filter((element) => { + const style = (globalThis as any).getComputedStyle(element); + return ["auto", "scroll"].includes(style.overflowY) && + element.scrollHeight > element.clientHeight + 1; + }) + .map((element) => `${element.tagName.toLowerCase()}.${element.className}`) + ); + assertEquals(nested, []); +} + +async function openSubjectPage( page: Page, baseUrl: string, mobile = false, ): Promise { - await page.goto(`${baseUrl}/w/${workspaceId}/memory`); - const heading = page.locator("h1", { hasText: "Workspace Memory" }); + await page.goto(`${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}`); + const heading = page.locator("h1", { hasText: "Release coordination" }); await heading.waitFor({ state: "attached" }); - if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click(); + if (mobile) { + const showSidebar = page.getByRole("button", { name: "Show sidebar" }); + await showSidebar.waitFor(); + assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + false, + ); + + await showSidebar.click(); + const hideSidebar = page.getByRole("button", { name: "Hide sidebar" }); + await hideSidebar.waitFor(); + assertEquals(await hideSidebar.getAttribute("aria-expanded"), "true"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + true, + ); + + await hideSidebar.click(); + await showSidebar.waitFor(); + assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); + assertEquals( + await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), + false, + ); + } await heading.waitFor(); } async function swipeMainToEnd(page: Page): Promise { const session = await page.context().newCDPSession(page); + const viewport = page.viewportSize(); + if (!viewport) throw new Error("touch viewport is unavailable"); + const x = Math.max(20, Math.min(300, viewport.width - 20)); + const startY = Math.max(120, viewport.height - 100); + const moveYs = [0.8, 0.62, 0.44, 0.26, 0.12].map((ratio) => + Math.max(20, Math.round(viewport.height * ratio)) + ); for (let attempt = 0; attempt < 100; attempt += 1) { const state = await mainScrollState(page); if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return; await session.send("Input.dispatchTouchEvent", { type: "touchStart", - touchPoints: [{ x: 300, y: 700, id: 1 }], + touchPoints: [{ x, y: startY, id: 1 }], }); - for (const y of [580, 460, 340, 220, 100]) { + for (const y of moveYs) { await session.send("Input.dispatchTouchEvent", { type: "touchMove", - touchPoints: [{ x: 300, y, id: 1 }], + touchPoints: [{ x, y, id: 1 }], }); } await session.send("Input.dispatchTouchEvent", { @@ -84,10 +143,10 @@ async function swipeMainToEnd(page: Page): Promise { }); await page.waitForTimeout(20); } - throw new Error("touch input did not reach the end of the Memory document"); + throw new Error("touch input did not reach the end of the resident Memory surface"); } -Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => { +Deno.test("production subject Memory shell renders exact states, safe Markdown, provenance, and one scroll owner", async () => { const build = await new Deno.Command(Deno.execPath(), { args: ["task", "build"], cwd: workspaceRoot, @@ -130,6 +189,13 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end colorScheme: "light" as const, mobile: true, }, + { + label: "compact-mobile", + width: 320, + height: 700, + colorScheme: "dark" as const, + mobile: true, + }, ] ) { const context = await browser.newContext({ @@ -144,21 +210,28 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end if (message.type() === "error") errors.push(message.text()); }); page.on("pageerror", (error) => errors.push(String(error))); - await openMemoryPage(page, baseUrl, scenario.mobile); + await openSubjectPage(page, baseUrl, scenario.mobile); const initial = await mainScrollState(page); assert(initial.scrollHeight > initial.clientHeight); assertEquals(await markerIsInsideMain(page), false); + await assertNoPageWideOverflow(page); + await assertMainOwnsVerticalScroll(page); assertEquals( - await page.evaluate(() => { - const document = (globalThis as any).document; - return document.documentElement.scrollWidth === document.documentElement.clientWidth; - }), - true, + await page.locator('aside a[href$="/memory"]', { hasText: "Subjects" }).count(), + 1, ); + assertEquals(await page.locator("aside a", { hasText: "Staging" }).count(), 0); if (scenario.label === "wheel") { - assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]); + assertEquals( + await page.getByRole("heading", { + name: "Release coordination", + level: 1, + exact: true, + }).count(), + 1, + ); assertEquals( await page.locator("strong").filter({ hasText: "important context" }).count() > 0, true, @@ -167,8 +240,8 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end assertEquals(await page.locator("ul li").count() > 0, true); for ( const heading of [ - { selector: "h5", text: "Deep document heading" }, - { selector: "h6", text: "Deepest document heading" }, + { selector: "h5", text: "Deep surface heading" }, + { selector: "h6", text: "Deepest surface heading" }, ] ) { const element = page.locator(heading.selector, { hasText: heading.text }); @@ -182,8 +255,17 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end } assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); - assertEquals(await page.locator("article img, article script").count(), 0); - assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0); + assertEquals( + await page.getByRole("article", { name: "Resident surface" }).locator("img, script") + .count(), + 0, + ); + for (const state of ["Active", "Resolved", "Retracted"]) { + assertEquals( + await page.locator(".memory-pill", { hasText: state }).count(), + 1, + ); + } for (const label of ["Markdown table", "rust code block"]) { const region = page.getByRole("region", { name: label }); @@ -194,7 +276,7 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end await page.locator("main").hover(); await page.mouse.wheel(0, 100_000); } else if (scenario.label === "keyboard") { - await page.getByText("Document details", { exact: true }).focus(); + await page.getByText("Surface provenance", { exact: true }).focus(); await page.keyboard.press("End"); } else { await swipeMainToEnd(page); @@ -213,33 +295,72 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end await context.close(); } - const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); - const emptyPage = await stateContext.newPage(); - await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - body_md: " \n", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: 2, - record_source: "fixture", - }), - }); - }); - await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`); - await emptyPage.getByText("Memory document is empty.").waitFor(); - assertEquals(await emptyPage.getByRole("alert").count(), 0); + const detailContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const detailPage = await detailContext.newPage(); + await detailPage.goto( + `${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}/${ + encodeURIComponent(memoryId) + }`, + ); + await detailPage.getByRole("heading", { + name: "Keep subject Memory provenance typed and visible", + level: 1, + }).waitFor(); + assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); + assertEquals(await detailPage.getByText("T-672 product direction").count(), 1); + assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); + assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); + assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 1); + assertEquals(await detailPage.locator('a[href^="javascript:"]').count(), 0); + assertEquals( + await detailPage.getByRole("article", { name: "Committed Memory body" }).locator( + "script, img", + ).count(), + 0, + ); + for (const label of ["Markdown table", "text code block"]) { + const region = detailPage.getByRole("region", { name: label }); + assertEquals(await region.getAttribute("tabindex"), "0"); + assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth)); + } + await assertNoPageWideOverflow(detailPage); + await assertMainOwnsVerticalScroll(detailPage); + await detailContext.close(); - const errorPage = await stateContext.newPage(); - await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { - await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }); - }); - await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`); - const alert = errorPage.getByRole("alert"); - await alert.waitFor(); - assert((await alert.textContent())?.includes("Memory document unavailable.")); - assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0); + const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const stateCases = [ + { + id: "empty-subject", + text: "Resident surface is ready and empty.", + role: "status" as const, + }, + { id: "stale-subject", text: "Resident surface is stale.", role: "status" as const }, + { + id: "failed-subject", + text: "Resident surface generation failed.", + role: "alert" as const, + }, + { + id: "ungenerated-subject", + text: "Resident surface has not been generated.", + role: "status" as const, + }, + { id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const }, + ]; + for (const stateCase of stateCases) { + const page = await stateContext.newPage(); + await page.goto(`${baseUrl}/w/${workspaceId}/memory/${stateCase.id}`); + const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text }); + await state.waitFor(); + assert((await state.textContent())?.includes(stateCase.text)); + if (stateCase.id !== "empty-subject") { + assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0); + } + await assertNoPageWideOverflow(page); + await page.close(); + } await stateContext.close(); } finally { await browser.close(); diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json new file mode 100644 index 000000000..c09324b6d --- /dev/null +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "id": "memory-subjects-dark", + "title": "Memory subjects, surfaces, and committed records — dark", + "baseUrl": "http://127.0.0.1:15286", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Read-only Memory fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [ + { + "id": "subjects", + "label": "Subject index", + "path": "/w/memory-review/memory", + "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", + "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", + "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, + "capturePoints": [{ "id": "initial", "label": "Subjects" }] + }, + { + "id": "representative", + "label": "Ready subject surface", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + "goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.", + "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + }, + "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + }, + { + "id": "detail", + "label": "Committed Memory detail", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", + "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "capturePoints": [{ "id": "initial", "label": "Memory detail" }] + }, + { + "id": "empty", + "label": "Ready-empty subject", + "path": "/w/memory-review/memory/empty-subject", + "goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.", + "dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]" + }, + "capturePoints": [{ "id": "initial", "label": "Ready empty" }] + }, + { + "id": "stale", + "label": "Stale subject surface", + "path": "/w/memory-review/memory/stale-subject", + "goal": "Recognize that no snapshot is displayable while committed Memories remain readable.", + "dataState": "Stale availability without snapshot and one resolved committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='stale']" + }, + "capturePoints": [{ "id": "initial", "label": "Stale" }] + }, + { + "id": "failed", + "label": "Failed subject surface", + "path": "/w/memory-review/memory/failed-subject", + "goal": "See generation failure without displaying a failed or partial snapshot.", + "dataState": "Failed availability without snapshot and one retracted committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='failed']" + }, + "capturePoints": [{ "id": "initial", "label": "Failed" }] + }, + { + "id": "error", + "label": "Subject endpoint error", + "path": "/w/memory-review/memory/error-subject", + "goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.", + "dataState": "Intentional HTTP 503 from all subject-scoped endpoints.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='error']" + }, + "capturePoints": [{ "id": "initial", "label": "Unavailable" }] + } + ], + "processes": [{ + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15286", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15286/health" + }] +} diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json new file mode 100644 index 000000000..63aebb43e --- /dev/null +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 1, + "id": "memory-subjects-light", + "title": "Memory subjects, surfaces, and committed records — light", + "baseUrl": "http://127.0.0.1:15285", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [{ + "id": "fixture", + "label": "Read-only Memory fixture", + "auth": { "kind": "anonymous" } + }], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 }, + { "label": "compact", "width": 320, "height": 700 } + ], + "routes": [ + { + "id": "subjects", + "label": "Subject index", + "path": "/w/memory-review/memory", + "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", + "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", + "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, + "capturePoints": [{ "id": "initial", "label": "Subjects" }] + }, + { + "id": "representative", + "label": "Ready subject surface", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review", + "goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.", + "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + }, + "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + }, + { + "id": "detail", + "label": "Committed Memory detail", + "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", + "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", + "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", + "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "capturePoints": [{ "id": "initial", "label": "Memory detail" }] + }, + { + "id": "empty", + "label": "Ready-empty subject", + "path": "/w/memory-review/memory/empty-subject", + "goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.", + "dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]" + }, + "capturePoints": [{ "id": "initial", "label": "Ready empty" }] + }, + { + "id": "stale", + "label": "Stale subject surface", + "path": "/w/memory-review/memory/stale-subject", + "goal": "Recognize that no snapshot is displayable while committed Memories remain readable.", + "dataState": "Stale availability without snapshot and one resolved committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='stale']" + }, + "capturePoints": [{ "id": "initial", "label": "Stale" }] + }, + { + "id": "failed", + "label": "Failed subject surface", + "path": "/w/memory-review/memory/failed-subject", + "goal": "See generation failure without displaying a failed or partial snapshot.", + "dataState": "Failed availability without snapshot and one retracted committed Memory.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='failed']" + }, + "capturePoints": [{ "id": "initial", "label": "Failed" }] + }, + { + "id": "error", + "label": "Subject endpoint error", + "path": "/w/memory-review/memory/error-subject", + "goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.", + "dataState": "Intentional HTTP 503 from all subject-scoped endpoints.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='error']" + }, + "capturePoints": [{ "id": "initial", "label": "Unavailable" }] + } + ], + "processes": [{ + "id": "memory-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/memory_document_fixture_server.ts", + "15285", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15285/health" + }] +} diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 5f899c88a..a254e53a6 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index c01a71858..574fe4891 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -11,98 +11,56 @@ export const MEMORY_API_LIMITS = { export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type MemoryDocumentResponse = { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; -}; +export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, }; -export type MemoryCandidateKind = - | "preference" - | "working_assumption" - | "constraint" - | "decision" - | "open_question" - | "lesson"; +export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson"; -export type MemoryEvidenceOriginKind = - | "human_input" - | "worker_input" - | "flow_instruction" - | "backend_instruction" - | "model_output" - | "tool_output" - | "derived_summary" - | "legacy_unknown"; +export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown"; -export type MemoryEvidenceOrigin = { - kind: MemoryEvidenceOriginKind; - account_id?: string | null; - workspace_id?: string | null; - runtime_id?: string | null; - worker_id?: string | null; - flow_selector?: string | null; - flow_definition_id?: string | null; - flow_definition_revision?: number | null; -}; +export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, }; -export type MemorySourceRef = { segment_id: string; range: [number, number] }; +export type MemorySourceRef = { segment_id: string, range: [number, number], }; -export type MemoryStagingEvidence = { - id: string; - kind: string; - entry_range: [number, number] | null; - origin?: MemoryEvidenceOrigin | null; - excerpt: string | null; - summary: string | null; -}; +export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, }; -export type MemorySourceEvidenceRef = { - session_id: string | null; - segment_id: string | null; - entry_range: [number, number] | null; - evidence_id: string | null; - origin?: MemoryEvidenceOrigin | null; - evidence_kind: string | null; - label: string | null; - summary: string | null; -}; +export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, }; -export type MemoryStagingRecord = { - schema_version: number; - id: string; - extract_run_id: string; - source: MemorySourceRef; - kind: MemoryCandidateKind; - claim: string; - why_useful: string; - staleness: string | null; - evidence: Array; - source_refs: Array; -}; +export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array, source_refs: Array, }; -export type MemoryStagingEntry = { - id: string; - byte_len: number; - record: MemoryStagingRecord; -}; +export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, }; -export type MemoryStagingListResponse = { - limit: number; - returned_count: number; - total_valid_count: number; - invalid_count: number; - truncated: boolean; - order: string; - record_authority: string; - items: Array; - diagnostics: Array; -}; +export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array, diagnostics: Array, }; + +export type SubjektivSubjectState = "active" | "retired"; + +export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; + +export type SubjektivSubjectListResponse = { limit: number, items: Array, has_more: boolean, }; + +export type SubjektivMemoryState = "active" | "resolved" | "retracted"; + +export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, }; + +export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready"; + +export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array, built_from_store_revision: number, created_at: string, }; + +export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, }; + +export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, }; + +export type SubjektivMemoryQueryResponse = { items: Array, next_cursor?: string | null, has_more: boolean, }; + +export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, }; + +export type SubjektivMemorySourceEvidenceRef = { session_id?: string | null, segment_id?: string | null, entry_range?: [number, number] | null, evidence_id?: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind?: string | null, label?: string | null, summary?: string | null, }; + +export type SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array, evidence_total: number, evidence_truncated: boolean, source_refs: Array, source_refs_total: number, source_refs_truncated: boolean, }; + +export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array, source_candidates: Array, derived_from: Array, evidence_next_cursor?: string | null, evidence_has_more: boolean, }; + +export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, }; + +export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 59b5e68a2..2ea35b98e 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 9f33fc5dd..5d6d6539e 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index a1d54d8ed..6438d4f38 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index f0cec690a..bf78b1ad7 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) +// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 467a8030f..f1fce0990 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -96,11 +96,7 @@ function optional( label: string, parse: (item: unknown, label: string) => T, ): T | null | undefined { - return value === undefined - ? undefined - : value === null - ? null - : parse(value, label); + return value === undefined ? undefined : value === null ? null : parse(value, label); } function diagnostic(value: unknown, label: string): Diagnostic { @@ -388,17 +384,13 @@ export function parseWorkerSummary( `${label}.singleton_key`, string, ), - tags: item.tags === undefined - ? [] - : array(item.tags, `${label}.tags`, string), + tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string), workspace: workspaceSummary(item.workspace, `${label}.workspace`), availability, state: string(item.state, `${label}.state`), worker_state: workerState, last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string), - pinned: item.pinned === undefined - ? false - : boolean(item.pinned, `${label}.pinned`), + pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`), retention_state: item.retention_state === undefined ? "" : string(item.retention_state, `${label}.retention_state`), @@ -406,13 +398,11 @@ export function parseWorkerSummary( item.implementation, `${label}.implementation`, ), - workdir_attachments: item.workdir_attachments === undefined - ? undefined - : array( - item.workdir_attachments, - `${label}.workdir_attachments`, - workerWorkdirAttachment, - ), + workdir_attachments: item.workdir_attachments === undefined ? undefined : array( + item.workdir_attachments, + `${label}.workdir_attachments`, + workerWorkdirAttachment, + ), diagnostics: item.diagnostics === undefined ? [] : array(item.diagnostics, `${label}.diagnostics`, diagnostic), @@ -622,17 +612,22 @@ function workerSummary( item.implementation, `${label}.implementation`, ), - workdir_attachments: item.workdir_attachments === undefined - ? undefined - : array( - item.workdir_attachments, - `${label}.workdir_attachments`, - runtimeWorkdirAttachment, - ), + workdir_attachments: item.workdir_attachments === undefined ? undefined : array( + item.workdir_attachments, + `${label}.workdir_attachments`, + runtimeWorkdirAttachment, + ), diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic), }; } +export function parseWorkerLaunchWorkerSummary( + value: unknown, + label = "Worker launch summary", +): WorkerLaunchWorkerSummary { + return workerSummary(value, label); +} + export function parseBrowserCreateWorkerResponse( value: unknown, ): BrowserCreateWorkerResponse { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 6c806c73b..3c51b1ec5 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -448,27 +448,39 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () const memoryNav = await Deno.readTextFile( new URL("../sidebar/MemoryNavSection.svelte", import.meta.url), ); - const memoryDocumentLoad = await Deno.readTextFile( + const subjectIndexLoad = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url, ), ); - const memoryDocumentPage = await Deno.readTextFile( + const subjectIndexPage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/+page.svelte", import.meta.url, ), ); - const memoryStagingLoad = await Deno.readTextFile( + const subjectPageLoad = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/staging/+page.ts", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.ts", import.meta.url, ), ); - const memoryStagingPage = await Deno.readTextFile( + const subjectPage = await Deno.readTextFile( new URL( - "./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", + "./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte", + import.meta.url, + ), + ); + const memoryDetailLoad = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts", + import.meta.url, + ), + ); + const memoryDetailPage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte", import.meta.url, ), ); @@ -478,35 +490,44 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () memoryNav.includes( '', ) && - memoryNav.includes("Document") && + memoryNav.includes("Subjects") && + memoryNav.includes("currentPath.startsWith(`${subjectsHref}/`)") && memoryNav.includes("sidebar-nav-section--category") && memoryNav.includes('class="sidebar-link"') && - memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') && - memoryNav.includes("Staging") && - !memoryNav.includes("item-meta") && - !memoryNav.includes("durable workspace memory") && - !memoryNav.includes("pending extraction candidates"), - "Memory sidebar section should show Document and Staging as compact single-line links", + !memoryNav.includes("Staging") && + !memoryNav.includes("Document"), + "Memory sidebar section should expose one Subjects link for the full nested route tree", ); assert( - memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") && - memoryDocumentLoad.includes('"/memory"') && - memoryDocumentPage.includes("DocumentMarkdown from") && - memoryDocumentPage.includes("Read-only") && - memoryDocumentPage.includes("data.memory.data.body_md") && - memoryDocumentPage.includes("data.memory.data.updated_at") && - memoryDocumentPage.includes('role="alert"') && - !memoryDocumentPage.includes('
Subjects") &&
+      subjectIndexPage.includes("subject.store_revision") &&
+      subjectIndexPage.includes('data-memory-view="subjects"'),
+    "Memory product entry should list explicit typed subjects",
   );
   assert(
-    memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
-      memoryStagingLoad.includes('"/memory/staging"') &&
-      memoryStagingPage.includes("Memory Staging") &&
-      memoryStagingPage.includes("Workspace Server memory authority") &&
-      memoryStagingPage.includes("data.staging.data.invalid_count") &&
-      memoryStagingPage.includes("entry.record.evidence"),
-    "Memory Staging page should read the scoped API and expose staged records without mutation controls",
+    subjectPageLoad.includes("`${subjectPath}/surface`") &&
+      subjectPageLoad.includes("`${subjectPath}/memories`") &&
+      subjectPage.includes("DocumentMarkdown from") &&
+      subjectPage.includes("Resident surface") &&
+      subjectPage.includes("Current Memories") &&
+      subjectPage.includes("surface?.availability === 'ready'") &&
+      subjectPage.includes("surface?.availability === 'stale'") &&
+      subjectPage.includes("data-surface-ready-empty") &&
+      !subjectPage.includes("overflow-y"),
+    "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
+  );
+  assert(
+    memoryDetailLoad.includes("`${memoryPath}/revisions`") &&
+      memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") &&
+      memoryDetailPage.includes("Candidate provenance") &&
+      memoryDetailPage.includes("Source refs") &&
+      memoryDetailPage.includes("Derived from") &&
+      memoryDetailPage.includes("Revision history") &&
+      memoryDetailPage.includes("DocumentMarkdown from") &&
+      !memoryDetailPage.includes("overflow-y"),
+    "Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls",
   );
 });
 
@@ -719,8 +740,12 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
 
 Deno.test("Console spacing and text metrics use existing design tokens", async () => {
   const files = [
-    "ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
-    "ConsoleTasks.svelte", "WorkerRunStatus.svelte", "WorkerContextStatus.svelte",
+    "ComposerInput.svelte",
+    "ConsoleLineItem.svelte",
+    "RichMarkdown.svelte",
+    "ConsoleTasks.svelte",
+    "WorkerRunStatus.svelte",
+    "WorkerContextStatus.svelte",
     "ConsoleDisplayState.svelte",
     "ConsoleTurnNavigation.svelte",
     "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
@@ -729,7 +754,8 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
     const source = await Deno.readTextFile(new URL(file, import.meta.url));
     const css = source.split("
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
index 65205285c..49f9c9148 100644
--- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
+++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts
@@ -1,20 +1,19 @@
-import { MEMORY_API_LIMITS } from "$lib/generated/memory-api";
 import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
-import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
+import {
+  MEMORY_API_LOAD_POLICY,
+  parseSubjektivSubjectListResponse,
+} from "$lib/workspace/memory/api";
 import type { PageLoad } from "./$types";
 
 export const load: PageLoad = async ({ fetch, params }) => {
   return {
     workspaceId: params.workspaceId,
-    memory: await loadJson(
+    subjects: await loadJson(
       fetch,
-      workspaceApiPath(params.workspaceId, "/memory"),
+      `${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
       undefined,
-      parseMemoryDocumentResponse,
-      {
-        diagnosticLabel: "Memory API",
-        maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
-      },
+      parseSubjektivSubjectListResponse,
+      MEMORY_API_LOAD_POLICY,
     ),
   };
 };
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte
new file mode 100644
index 000000000..8330b06ff
--- /dev/null
+++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte
@@ -0,0 +1,470 @@
+
+
+
+  {data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace
+  
+
+
+
+ ← All subjects + +
+
+

Memory subject

+

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

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

Subject details are unavailable.

+ {/if} + +
+
+
+

Resident context

+

Resident surface

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

This subject currently has no resident context to display.

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

Committed Memories remain available below.

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

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

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

Resident surface data is unavailable.

+ {/if} +
+ +
+
+
+

Committed records

+

Current Memories

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

This subject has no current Memory revisions.

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

More current Memories are available after this bounded page.

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

Current Memory data is unavailable.

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

{memory.claim}

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

Memory

+

Why useful: {memory.why_useful}

+ {#if memory.staleness}

Staleness: {memory.staleness}

{/if} +

Change reason: {memory.change_reason}

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

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

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

Audit context

Candidate provenance

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

No candidate evidence is included on this page.

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

Candidate

+ {candidate.candidate_id} +
+ +
+

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

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

No evidence anchors on this page.

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

    {evidence.summary}

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

Additional evidence anchors are available.

{/if} +
+ +
+

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

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

No source refs on this page.

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

    {reference.summary}

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

Additional source refs are available.

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

Candidate evidence continues on another bounded page.

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

Derived from

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

No derivation references.

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

Immutable record

Revision history

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

No revision history is available.

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

    {revision.claim}

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

More immutable revisions are available after this bounded page.

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

Revision history data is unavailable.

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

Committed Memory

{data.memoryId}

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

{data.memoryId}

+

Memory data is unavailable.

+ {/if} +
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts new file mode 100644 index 000000000..e4b492583 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts @@ -0,0 +1,82 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + MEMORY_API_LOAD_POLICY, + parseSubjektivMemoryListRevisionsResponse, + parseSubjektivMemoryReadResponse, + parseSubjektivSubjectResponse, +} from "$lib/workspace/memory/api"; +import type { PageLoad } from "./$types"; + +const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/; + +function safeIntegerQuery( + source: URLSearchParams, + key: string, + positive = false, +): string | null { + const value = source.get(key); + if (value === null || !SAFE_INTEGER_TEXT.test(value)) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || (positive ? parsed < 1 : parsed < 0)) { + return null; + } + return String(parsed); +} + +export const load: PageLoad = async ({ fetch, params, url }) => { + const subjectId = params.subjectId; + const memoryId = params.memoryId; + const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`; + const memoryPath = `${subjectPath}/memories/${encodeURIComponent(memoryId)}`; + const detailQuery = new URLSearchParams({ limit: "1000" }); + for ( + const [key, positive] of [ + ["revision", true], + ["offset", false], + ["byte_offset", false], + ] as const + ) { + const value = safeIntegerQuery(url.searchParams, key, positive); + if (value !== null) detailQuery.set(key, value); + } + const evidenceCursor = url.searchParams.get("evidence_cursor"); + if ( + evidenceCursor !== null && evidenceCursor.length > 0 && + new TextEncoder().encode(evidenceCursor).byteLength <= 512 + ) { + detailQuery.set("evidence_cursor", evidenceCursor); + } + + const [subject, memory, revisions] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(params.workspaceId, subjectPath), + undefined, + (value) => parseSubjektivSubjectResponse(value, subjectId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, memoryPath)}?${detailQuery}`, + undefined, + (value) => parseSubjektivMemoryReadResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + loadJson( + fetch, + `${workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)}?limit=100`, + undefined, + (value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId), + MEMORY_API_LOAD_POLICY, + ), + ]); + + return { + workspaceId: params.workspaceId, + subjectId, + memoryId, + subject, + memory, + revisions, + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index f1152218d..f3e36bda4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -2,75 +2,250 @@ import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; import { afterEach, expect, test } from "vitest"; -import MemoryPage from "./+page.svelte"; -import type { PageProps } from "./$types"; - -function pageProps(memory: { - data: { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; - } | null; - error: string | null; -}): PageProps { - return { - data: { workspaceId: "workspace-1", memory }, - params: { workspaceId: "workspace-1" }, - } as unknown as PageProps; -} +import SubjectIndexPage from "./+page.svelte"; +import SubjectPage from "./[subjectId]/+page.svelte"; +import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte"; afterEach(cleanup); -test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => { - const { container } = render( - MemoryPage, - pageProps({ - data: { - body_md: "# Durable context\n\nRead the **current** document.", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T03:04:05Z", - bytes: 48, - record_source: "sqlite_workspace_authority.memory_document", - }, - error: null, - }), - ); +const result = (data: T | null, error: string | null = null) => ({ + data, + error, +}); + +function subject() { + return { + id: "subject-1", + role: "Release coordinator", + state: "active" as const, + store_revision: 9, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + }; +} + +test("renders the subject index as the Memory product entry", () => { + render(SubjectIndexPage, { + data: { + workspaceId: "workspace-1", + subjects: result({ limit: 100, items: [subject()], has_more: false }), + }, + } as never); + + expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not + .toBeNull(); + expect(screen.getByRole("link", { name: /Release coordinator/ })).not + .toBeNull(); + expect(screen.getByText("subject-1")).not.toBeNull(); + expect(screen.queryByText("Staging")).toBeNull(); +}); + +test("renders a ready resident surface and committed Memory lifecycle states", async () => { + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + surface: result({ + subject_id: "subject-1", + availability: "ready" as const, + snapshot: { + snapshot_id: "snapshot-1", + body_md: "# Resident context\n\nUse the **current** committed record.", + memory_refs: [{ memory_id: "memory-1", revision: 3 }], + built_from_store_revision: 9, + created_at: "2026-01-02T03:04:05Z", + }, + }), + memories: result({ + items: [ + { + id: "memory-1", + revision: 3, + kind: "decision" as const, + state: "active" as const, + claim: "Current decision", + excerpt: "Current excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + { + id: "memory-2", + revision: 2, + kind: "lesson" as const, + state: "resolved" as const, + claim: "Resolved lesson", + excerpt: "Resolved excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + { + id: "memory-3", + revision: 1, + kind: "constraint" as const, + state: "retracted" as const, + claim: "Retracted constraint", + excerpt: "Retracted excerpt", + updated_at: "2026-01-02T03:04:05Z", + }, + ], + has_more: false, + }), + }, + } as never); await waitFor(() => { - expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) + .not.toBeNull(); }); - expect(screen.getByText("Read-only")).not.toBeNull(); - expect(screen.getByText("Document details")).not.toBeNull(); - expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false); - expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull(); - expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull(); + expect(screen.getByRole("article", { name: "Resident surface" })).not + .toBeNull(); + expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan( + 0, + ); + expect(screen.getByText("resolved", { exact: true })).not.toBeNull(); + expect(screen.getByText("retracted", { exact: true })).not.toBeNull(); }); -test("distinguishes an empty document from request failure and unavailable data", async () => { - const view = render( - MemoryPage, - pageProps({ +test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { + const base = { + workspaceId: "workspace-1", + subjectId: "subject-1", + subject: result(subject()), + memories: result({ items: [], has_more: false }), + }; + const view = render(SubjectPage, { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "ready" as const, + snapshot: { + snapshot_id: "snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 9, + created_at: "2026-01-02T03:04:05Z", + }, + }), + }, + } as never); + expect(screen.getByText("Resident surface is ready and empty.")).not + .toBeNull(); + + await view.rerender( + { data: { - body_md: " \n", + ...base, + surface: result({ + subject_id: "subject-1", + availability: "stale" as const, + }), + }, + } as never, + ); + expect(screen.getByText("Resident surface is stale.")).not.toBeNull(); + + await view.rerender( + { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "failed" as const, + }), + }, + } as never, + ); + expect(screen.getByRole("alert").textContent).toContain("generation failed"); + + await view.rerender({ data: { ...base, surface: result(null) } } as never); + expect(screen.getByText("Resident surface data is unavailable.")).not + .toBeNull(); + + await view.rerender( + { + data: { ...base, surface: result(null, "Memory API request failed") }, + } as never, + ); + expect(screen.getByRole("alert").textContent).toContain( + "Resident surface unavailable", + ); +}); + +test("renders committed Memory detail, provenance, derivation, and immutable revisions", async () => { + render(MemoryDetailPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + memoryId: "memory-1", + subject: result(subject()), + memory: result({ + memory_id: "memory-1", + revision: 2, + current_revision: 3, + kind: "decision" as const, + state: "resolved" as const, + claim: "Keep provenance typed", + body_md: "# Committed detail\n\nRead-only body.", + why_useful: "Prevents origin loss.", + change_reason: "Resolved after implementation.", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", - bytes: 3, - record_source: "fixture", - }, - error: null, - }), - ); - expect(screen.getByRole("status").textContent).toContain("Memory document is empty."); - expect(screen.queryByRole("alert")).toBeNull(); + body_offset: 0, + body_byte_offset: 0, + body_truncated: false, + source_candidate_ids: ["candidate-1"], + source_candidates: [{ + candidate_id: "candidate-1", + evidence: [{ + id: "evidence-1", + kind: "message", + summary: "Explicit decision.", + }], + evidence_total: 1, + evidence_truncated: false, + source_refs: [{ + session_id: "session-1", + segment_id: "segment-1", + evidence_id: "evidence-1", + label: "Decision discussion", + }], + source_refs_total: 1, + source_refs_truncated: false, + }], + derived_from: [{ memory_id: "memory-parent", revision: 4 }], + evidence_has_more: false, + }), + revisions: result({ + memory_id: "memory-1", + current_revision: 3, + items: [{ + revision: 3, + kind: "decision" as const, + state: "active" as const, + claim: "Current claim", + change_reason: "Clarified", + updated_at: "2026-01-03T00:00:00Z", + }, { + revision: 2, + kind: "decision" as const, + state: "resolved" as const, + claim: "Earlier claim", + change_reason: "Resolved", + updated_at: "2026-01-02T00:00:00Z", + }], + has_more: false, + }), + }, + } as never); - await view.rerender(pageProps({ data: null, error: "Memory API request failed" })); - expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable."); - expect(screen.getByRole("alert").textContent).toContain("Memory API request failed"); - expect(screen.queryByText("Memory document is empty.")).toBeNull(); - - await view.rerender(pageProps({ data: null, error: null })); - expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable."); - expect(screen.queryByRole("alert")).toBeNull(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Committed detail", level: 1 })) + .not.toBeNull(); + }); + expect(screen.getByText("candidate-1")).not.toBeNull(); + expect(screen.getByText("Decision discussion")).not.toBeNull(); + expect(screen.getByText("memory-parent")).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Revision history" })).not + .toBeNull(); + expect(screen.getByText("Historical revision")).not.toBeNull(); }); diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 8d01e387b..b4eb3d88b 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -3,12 +3,29 @@ declare const Deno: { }; import { - parseMemoryDocumentResponse, parseMemoryStagingListResponse, + parseSubjektivMemoryListRevisionsResponse, + parseSubjektivMemoryQueryResponse, + parseSubjektivMemoryReadResponse, + parseSubjektivResidentSurfaceResponse, + parseSubjektivSubjectListResponse, + parseSubjektivSubjectResponse, } from "../src/lib/workspace/memory/api.ts"; +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, stable(item)]), + ); + } + return value; +} + function assertEquals(actual: unknown, expected: unknown): void { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { + if (JSON.stringify(stable(actual)) !== JSON.stringify(stable(expected))) { throw new Error( `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, ); @@ -27,8 +44,386 @@ function assertThrows(fn: () => void, expectedMessage: string): void { throw new Error(`expected function to throw ${expectedMessage}`); } -function fixture(origin: Record) { +function subject(id = "subject-1") { return { + id, + role: "Release coordinator", + state: "active", + store_revision: 12, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T00:00:00Z", + }; +} + +function queryItem(id = "memory-1") { + return { + id, + revision: 3, + kind: "decision", + state: "active", + claim: "Keep provenance typed.", + excerpt: "Preserve source and derivation references.", + updated_at: "2026-09-02T03:04:05Z", + }; +} + +function detailFixture() { + const origin = { + kind: "flow_instruction", + workspace_id: "workspace-1", + runtime_id: "runtime-1", + worker_id: "worker-1", + flow_selector: "builtin:coder-review", + flow_definition_id: "flow-1", + flow_definition_revision: 7, + }; + return { + memory_id: "memory-1", + revision: 2, + current_revision: 3, + kind: "decision", + state: "resolved", + claim: "Keep provenance typed.", + body_md: "# Committed Memory\n\nBounded body segment.", + why_useful: "Prevents origin loss.", + staleness: null, + change_reason: "Resolved after implementation.", + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T03:04:05Z", + body_offset: 0, + body_byte_offset: 0, + body_next_offset: 2, + body_next_byte_offset: 0, + body_truncated: true, + source_candidate_ids: ["candidate-1"], + source_candidates: [{ + candidate_id: "candidate-1", + evidence: [{ + id: "evidence-1", + kind: "message", + entry_range: [10, 12], + origin, + excerpt: "Use exact DTOs.", + summary: "The decision was explicit.", + }], + evidence_total: 2, + evidence_truncated: true, + source_refs: [{ + session_id: "session-1", + segment_id: "segment-1", + entry_range: [10, 12], + evidence_id: "evidence-1", + origin, + evidence_kind: "message", + label: "Decision discussion", + summary: "Bounded host summary.", + }], + source_refs_total: 2, + source_refs_truncated: true, + }], + derived_from: [{ memory_id: "memory-parent", revision: 4 }], + evidence_next_cursor: "evidence-page-2", + evidence_has_more: true, + }; +} + +Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bounds", () => { + assertEquals( + parseSubjektivSubjectResponse(subject(), "subject-1"), + subject(), + ); + assertThrows( + () => parseSubjektivSubjectResponse(subject(), "another-subject"), + "identity does not match", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), state: "paused" }), + "unknown Subject state", + ); + assertThrows( + () => + parseSubjektivSubjectResponse({ + ...subject(), + store_revision: Number.MAX_SAFE_INTEGER + 1, + }), + "safe integer", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), id: "x".repeat(513) }), + "bounded identifier", + ); + assertThrows( + () => parseSubjektivSubjectResponse({ ...subject(), future: true }), + "unknown field", + ); + assertThrows( + () => + parseSubjektivSubjectListResponse({ + limit: 100, + items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)), + has_more: false, + }), + "bounded array", + ); +}); + +Deno.test("Subject list parser preserves the exact bounded response", () => { + const response = { limit: 100, items: [subject()], has_more: false }; + assertEquals(parseSubjektivSubjectListResponse(response), response); + assertThrows( + () => + parseSubjektivSubjectListResponse({ + limit: 100, + items: [subject("duplicate"), subject("duplicate")], + has_more: false, + }), + "Subject ids must be unique", + ); +}); + +Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => { + const readyEmpty = { + subject_id: "subject-1", + availability: "ready", + snapshot: { + snapshot_id: "snapshot-empty", + body_md: "", + memory_refs: [], + built_from_store_revision: 0, + created_at: "2026-09-02T00:00:00Z", + }, + }; + assertEquals( + parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-1"), + readyEmpty, + ); + assertEquals( + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "ungenerated", + }), + { subject_id: "subject-1", availability: "ungenerated" }, + ); + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "ready", + }), + "requires a snapshot", + ); + for (const availability of ["ungenerated", "stale", "failed"]) { + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + ...readyEmpty, + availability, + }), + "must not include a snapshot", + ); + } + assertThrows( + () => + parseSubjektivResidentSurfaceResponse({ + subject_id: "subject-1", + availability: "refreshing", + }), + "unknown resident surface availability", + ); + assertThrows( + () => parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-2"), + "identity does not match", + ); +}); + +Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, cursors, and oversized pages", () => { + const response = { + items: [queryItem()], + has_more: true, + next_cursor: "memory-page-2", + }; + assertEquals(parseSubjektivMemoryQueryResponse(response), response); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), state: "pending" }], + }), + "unknown Memory state", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), kind: "future_kind" }], + }), + "unknown Memory candidate kind", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + ...response, + items: [{ ...queryItem(), revision: 0 }], + }), + "positive safe integer", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + items: [queryItem()], + has_more: true, + }), + "cursor does not match", + ); + assertThrows( + () => + parseSubjektivMemoryQueryResponse({ + items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)), + has_more: false, + }), + "bounded array", + ); +}); + +Deno.test("Memory detail parser preserves body/evidence continuation, sources, origins, and derivation", () => { + const fixture = detailFixture(); + const parsed = parseSubjektivMemoryReadResponse(fixture, "memory-1"); + assertEquals(parsed, fixture); + assertEquals(parsed.body_next_offset, 2); + assertEquals(parsed.body_next_byte_offset, 0); + assertEquals(parsed.evidence_next_cursor, "evidence-page-2"); + assertEquals( + parsed.source_candidates[0].evidence[0].origin, + fixture.source_candidates[0].evidence[0].origin, + ); + assertEquals( + parsed.source_candidates[0].source_refs, + fixture.source_candidates[0].source_refs, + ); + assertEquals(parsed.derived_from, [{ + memory_id: "memory-parent", + revision: 4, + }]); +}); + +Deno.test("Memory detail parser fails closed on identity, variants, bounds, and continuation mismatch", () => { + const fixture = detailFixture(); + assertThrows( + () => parseSubjektivMemoryReadResponse(fixture, "memory-2"), + "identity does not match", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + current_revision: Number.MAX_SAFE_INTEGER + 1, + }), + "safe integer", + ); + const missingBodyContinuation = structuredClone(fixture) as Record< + string, + unknown + >; + delete missingBodyContinuation.body_next_byte_offset; + assertThrows( + () => parseSubjektivMemoryReadResponse(missingBodyContinuation), + "body continuation fields", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + source_candidate_ids: ["different-candidate"], + }), + "source candidate ids do not match", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + source_candidates: [{ + ...fixture.source_candidates[0], + evidence: [{ + ...fixture.source_candidates[0].evidence[0], + entry_range: [12, 10], + }], + }], + }), + "must be ordered", + ); + const unknownOrigin = structuredClone(fixture); + unknownOrigin.source_candidates[0].evidence[0].origin.kind = "future_origin"; + assertThrows( + () => parseSubjektivMemoryReadResponse(unknownOrigin), + "unknown Memory evidence origin kind", + ); + assertThrows( + () => + parseSubjektivMemoryReadResponse({ + ...fixture, + evidence_has_more: false, + }), + "cursor does not match", + ); +}); + +Deno.test("Memory revision parser preserves immutable states and checks response identity", () => { + const response = { + memory_id: "memory-1", + current_revision: 3, + items: [{ + revision: 3, + kind: "decision", + state: "active", + claim: "Current claim.", + change_reason: "Clarified wording.", + updated_at: "2026-09-03T00:00:00Z", + }, { + revision: 2, + kind: "decision", + state: "resolved", + claim: "Earlier claim.", + change_reason: "Resolved.", + updated_at: "2026-09-02T00:00:00Z", + }, { + revision: 1, + kind: "decision", + state: "retracted", + claim: "Original claim.", + change_reason: "Retracted.", + updated_at: "2026-09-01T00:00:00Z", + }], + has_more: false, + }; + assertEquals( + parseSubjektivMemoryListRevisionsResponse(response, "memory-1"), + response, + ); + assertThrows( + () => parseSubjektivMemoryListRevisionsResponse(response, "memory-2"), + "identity does not match", + ); + assertThrows( + () => + parseSubjektivMemoryListRevisionsResponse({ + ...response, + items: [{ ...response.items[0], state: "archived" }], + }), + "unknown Memory state", + ); + assertThrows( + () => + parseSubjektivMemoryListRevisionsResponse({ + ...response, + items: [{ ...response.items[0], revision: 4 }], + }), + "future revision", + ); +}); + +Deno.test("Legacy staging parser remains strict for deprecated compatibility", () => { + const origin = { kind: "worker_input", worker_id: "worker-1" }; + const response = { limit: 100, returned_count: 1, total_valid_count: 1, @@ -70,97 +465,19 @@ function fixture(origin: Record) { }], diagnostics: [], }; -} - -Deno.test("Memory document response requires the generated DTO fields", () => { - assertEquals( - parseMemoryDocumentResponse({ - body_md: "# Memory\n", - created_at: "2026-09-01T00:00:00Z", - updated_at: "2026-09-01T00:00:00Z", - bytes: 9, - record_source: "sqlite_workspace_authority.memory_document", - }).bytes, - 9, - ); - assertThrows( - () => parseMemoryDocumentResponse({ body_md: "# Memory\n" }), - "missing a required field", - ); -}); - -for ( - const [kind, fields] of [ - ["human_input", { account_id: "account-1" }], - [ - "worker_input", - { - workspace_id: "workspace-1", - runtime_id: "runtime-1", - worker_id: "worker-1", - }, - ], - ["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }], - ["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }], - ["legacy_unknown", {}], - ] as const -) { - Deno.test(`Memory staging parser preserves ${kind} origin`, () => { - const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields })); - assertEquals(parsed.items[0].record.evidence[0].origin, { - kind, - ...fields, - }); - assertEquals(parsed.items[0].record.source_refs[0].origin, { - kind, - ...fields, - }); - }); -} - -Deno.test("Memory staging parser preserves Flow origin fields", () => { - const origin = { - kind: "flow_instruction" as const, - workspace_id: "workspace-1", - runtime_id: "runtime-1", - worker_id: "worker-1", - flow_selector: "builtin:coder-review", - flow_definition_id: "flow-1", - flow_definition_revision: 7, - }; - const parsed = parseMemoryStagingListResponse(fixture(origin)); - assertEquals(parsed.items[0].record.source_refs[0].origin, origin); -}); - -Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => { - assertThrows( - () => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })), - "unknown Memory evidence origin kind", - ); + assertEquals(parseMemoryStagingListResponse(response), response); assertThrows( () => - parseMemoryStagingListResponse( - fixture({ kind: "human_input", future_field: "must not be accepted" }), - ), - "unknown field", - ); -}); - -Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => { - const malformed = fixture({ kind: "legacy_unknown" }); - malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [ - number, - number, - ]; - assertThrows( - () => parseMemoryStagingListResponse(malformed), - "two-item entry range", - ); - assertThrows( - () => - parseMemoryStagingListResponse( - fixture({ kind: "worker_input", worker_id: "x".repeat(513) }), - ), - "exceeds the Memory origin limit", + parseMemoryStagingListResponse({ + ...response, + items: [{ + ...response.items[0], + record: { + ...response.items[0].record, + source: { segment_id: "segment-1", range: [20, 10] }, + }, + }], + }), + "must be ordered", ); });