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

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

+ {#if data.cursor}

Return to the first page

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

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

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