fix subject authority and bounded memory browsing
This commit is contained in:
@@ -422,7 +422,10 @@ impl BackendWorkspaceProductClient {
|
|||||||
client
|
client
|
||||||
.subjektiv_subject_list(
|
.subjektiv_subject_list(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
SubjektivSubjectListQuery { limit: Some(limit) },
|
SubjektivSubjectListQuery {
|
||||||
|
limit: Some(limit),
|
||||||
|
cursor: None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -391,6 +391,12 @@ impl ResolvedSubjektivFeatureConfig {
|
|||||||
self.profile.enabled
|
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(
|
pub fn bind_workspace_settings(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings: WorkspaceMemorySettingsSnapshot,
|
settings: WorkspaceMemorySettingsSnapshot,
|
||||||
@@ -413,9 +419,9 @@ impl ResolvedSubjektivFeatureConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
||||||
if self.profile.enabled && self.workspace_settings.is_none() {
|
// `profile.enabled` is reusable policy. Only a host-bound settings
|
||||||
return Err("enabled subjektiv feature requires trusted Workspace 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() {
|
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||||
return Err("disabled subjektiv feature must not carry Workspace settings");
|
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());
|
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]
|
#[test]
|
||||||
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ pub struct CreateWorkerRequest {
|
|||||||
pub workspace_api: Option<WorkspaceApiRef>,
|
pub workspace_api: Option<WorkspaceApiRef>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||||
|
/// 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)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
|||||||
@@ -4605,6 +4605,8 @@ pub struct SubjektivSubjectListQuery {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[schemars(range(min = 1, max = 100))]
|
#[schemars(range(min = 1, max = 100))]
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||||
@@ -4613,6 +4615,8 @@ pub struct SubjektivSubjectListResponse {
|
|||||||
#[schemars(range(min = 1, max = 100))]
|
#[schemars(range(min = 1, max = 100))]
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
pub items: Vec<SubjektivSubjectResponse>,
|
pub items: Vec<SubjektivSubjectResponse>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
pub has_more: bool,
|
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 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<SubjektivSubjectResponse>, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ pub struct CreateWorkerRequest {
|
|||||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||||
|
/// 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.
|
/// Last persisted Worker lifecycle status.
|
||||||
|
|||||||
@@ -3102,6 +3102,7 @@ mod tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4081,6 +4082,7 @@ mod ws_tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5992,6 +5992,7 @@ mod tests {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -944,7 +944,7 @@ fn bind_workspace_memory_settings(
|
|||||||
request: &CreateWorkerRequest,
|
request: &CreateWorkerRequest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(snapshot) = request.memory_settings.as_ref() else {
|
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(
|
return Err(
|
||||||
"Workspace Worker request is missing its bound Memory settings snapshot"
|
"Workspace Worker request is missing its bound Memory settings snapshot"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -967,7 +967,12 @@ fn bind_workspace_memory_settings(
|
|||||||
.bind_workspace_settings(snapshot.clone())
|
.bind_workspace_settings(snapshot.clone())
|
||||||
.map_err(str::to_string)?;
|
.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
|
manifest
|
||||||
.feature
|
.feature
|
||||||
.subjektiv
|
.subjektiv
|
||||||
@@ -992,6 +997,12 @@ fn validate_worker_memory_settings(
|
|||||||
request: &CreateWorkerRequest,
|
request: &CreateWorkerRequest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(expected) = request.memory_settings.as_ref() else {
|
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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
manifest
|
manifest
|
||||||
@@ -1004,30 +1015,50 @@ fn validate_worker_memory_settings(
|
|||||||
.subjektiv
|
.subjektiv
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
for (feature, enabled, actual) in [
|
if manifest.feature.memory.profile.enabled
|
||||||
(
|
&& manifest.feature.memory.workspace_settings().as_ref() != Some(expected)
|
||||||
"Memory",
|
{
|
||||||
manifest.feature.memory.profile.enabled,
|
let actual = manifest
|
||||||
manifest.feature.memory.workspace_settings(),
|
.feature
|
||||||
),
|
.memory
|
||||||
(
|
.workspace_settings()
|
||||||
"subjektiv",
|
.ok_or_else(|| {
|
||||||
manifest.feature.subjektiv.profile.enabled,
|
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||||
manifest.feature.subjektiv.workspace_settings(),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
if enabled && actual.as_ref() != Some(expected) {
|
|
||||||
let actual = actual.ok_or_else(|| {
|
|
||||||
format!("Workspace Worker restored without its bound {feature} settings snapshot")
|
|
||||||
})?;
|
})?;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Workspace Worker {feature} settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||||
expected.workspace_id,
|
expected.workspace_id,
|
||||||
expected.settings_revision,
|
expected.settings_revision,
|
||||||
actual.workspace_id,
|
actual.workspace_id,
|
||||||
actual.settings_revision
|
actual.settings_revision
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let subjektiv_settings = manifest.feature.subjektiv.workspace_settings();
|
||||||
|
if request.subjektiv_attached {
|
||||||
|
if !manifest.feature.subjektiv.profile.enabled {
|
||||||
|
return Err(
|
||||||
|
"subject-attached Worker restored with a Profile that disables subjektiv policy"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if subjektiv_settings.as_ref() != Some(expected) {
|
||||||
|
let actual = subjektiv_settings.ok_or_else(|| {
|
||||||
|
"subject-attached Worker restored without its bound subjektiv settings snapshot"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
return Err(format!(
|
||||||
|
"Workspace Worker subjektiv settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||||
|
expected.workspace_id,
|
||||||
|
expected.settings_revision,
|
||||||
|
actual.workspace_id,
|
||||||
|
actual.settings_revision
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if subjektiv_settings.is_some() {
|
||||||
|
return Err(
|
||||||
|
"ordinary Workspace Worker restored with an unauthorized subjektiv attachment"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -4412,9 +4443,70 @@ mod tests {
|
|||||||
worker_observation_grants: Vec::new(),
|
worker_observation_grants: Vec::new(),
|
||||||
workspace_api: None,
|
workspace_api: None,
|
||||||
memory_settings: 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]) {
|
fn git(path: &std::path::Path, args: &[&str]) {
|
||||||
let status = Command::new("git")
|
let status = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
|
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)
|
&& !is_builtin_subjektiv_consolidation_profile(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,10 +890,10 @@ impl SubjektivConsolidationFeatureInstallPlan {
|
|||||||
config
|
config
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
.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(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
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(|| {
|
let workspace_id = client.workspace_id().ok_or_else(|| {
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ impl SubjektivLifecycleFeature {
|
|||||||
workspace_context: WorkerWorkspaceContext,
|
workspace_context: WorkerWorkspaceContext,
|
||||||
event_tx: Option<broadcast::Sender<Event>>,
|
event_tx: Option<broadcast::Sender<Event>>,
|
||||||
) -> std::io::Result<Option<Self>> {
|
) -> std::io::Result<Option<Self>> {
|
||||||
if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled {
|
if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ impl SubjektivSurfaceLifecycleFeature {
|
|||||||
.subjektiv
|
.subjektiv
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
.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() {
|
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ impl SubjektivMemoryFeature {
|
|||||||
capture: CommittedSessionCaptureHandle,
|
capture: CommittedSessionCaptureHandle,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
) -> std::io::Result<Option<Self>> {
|
) -> std::io::Result<Option<Self>> {
|
||||||
if !config.profile.enabled {
|
if !config.execution_enabled() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ impl SubjektivSessionFeature {
|
|||||||
capture: CommittedSessionCaptureHandle,
|
capture: CommittedSessionCaptureHandle,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
) -> std::io::Result<Option<Self>> {
|
) -> std::io::Result<Option<Self>> {
|
||||||
if !config.profile.enabled {
|
if !config.execution_enabled() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -925,13 +925,7 @@ fn bind_child_memory_settings(
|
|||||||
parent: &manifest::WorkerManifest,
|
parent: &manifest::WorkerManifest,
|
||||||
child: &mut manifest::WorkerManifest,
|
child: &mut manifest::WorkerManifest,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !child.feature.memory.profile.enabled {
|
if child.feature.memory.profile.enabled {
|
||||||
return child
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.validate_execution()
|
|
||||||
.map_err(str::to_string);
|
|
||||||
}
|
|
||||||
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
|
||||||
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
|
||||||
.to_string()
|
.to_string()
|
||||||
@@ -941,10 +935,21 @@ fn bind_child_memory_settings(
|
|||||||
.memory
|
.memory
|
||||||
.bind_workspace_settings(workspace_settings)
|
.bind_workspace_settings(workspace_settings)
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
|
}
|
||||||
child
|
child
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.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
|
||||||
|
.subjektiv
|
||||||
|
.validate_execution()
|
||||||
.map_err(str::to_string)
|
.map_err(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,7 +1297,7 @@ enabled = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 runtime = TempDir::new().unwrap();
|
||||||
let workspace_root = runtime.path().join("project");
|
let workspace_root = runtime.path().join("project");
|
||||||
let bash_output_dir = runtime.path().join("bash-output");
|
let bash_output_dir = runtime.path().join("bash-output");
|
||||||
@@ -1310,6 +1315,17 @@ enabled = false
|
|||||||
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
||||||
deny: Vec::new(),
|
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 spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
|
||||||
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
||||||
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
||||||
@@ -1359,7 +1375,7 @@ enabled = false
|
|||||||
}));
|
}));
|
||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
"name": "reviewer-child",
|
"name": "reviewer-child",
|
||||||
"profile": "project:reviewer",
|
"profile": "builtin:reviewer",
|
||||||
"instruction": "role.reviewer",
|
"instruction": "role.reviewer",
|
||||||
"task": "review immutable commit",
|
"task": "review immutable commit",
|
||||||
"scope": [{
|
"scope": [{
|
||||||
@@ -1409,6 +1425,14 @@ enabled = false
|
|||||||
record.installed_tools
|
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!(
|
assert!(
|
||||||
!record
|
!record
|
||||||
.installed_tools
|
.installed_tools
|
||||||
@@ -1881,14 +1905,22 @@ enabled = false
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let mut parent = parent_manifest(temp.path(), None);
|
let mut parent = parent_manifest(temp.path(), None);
|
||||||
parent.feature.memory.profile.enabled = true;
|
parent.feature.memory.profile.enabled = true;
|
||||||
parent
|
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
|
||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
settings_revision: 4,
|
settings_revision: 4,
|
||||||
language: "日本語".to_string(),
|
language: "日本語".to_string(),
|
||||||
})
|
};
|
||||||
|
parent
|
||||||
|
.feature
|
||||||
|
.memory
|
||||||
|
.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();
|
.unwrap();
|
||||||
let mut child = parent.clone();
|
let mut child = parent.clone();
|
||||||
child.feature.memory.workspace_settings = None;
|
child.feature.memory.workspace_settings = None;
|
||||||
@@ -1898,6 +1930,15 @@ enabled = false
|
|||||||
child.feature.memory.workspace_settings(),
|
child.feature.memory.workspace_settings(),
|
||||||
parent.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;
|
child.feature.memory.profile.enabled = false;
|
||||||
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
|
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
|
||||||
|
|||||||
@@ -1037,34 +1037,15 @@ permission = "write"
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
let mut resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(workspace.path())
|
.with_workspace_base(workspace.path())
|
||||||
.resolve(
|
.resolve(
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
||||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
assert!(resolved.manifest.feature.subjektiv.enabled());
|
||||||
workspace_id: "workspace-test".to_string(),
|
assert!(!resolved.manifest.feature.subjektiv.execution_enabled());
|
||||||
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();
|
|
||||||
}
|
|
||||||
let workspace_context =
|
let workspace_context =
|
||||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
|
||||||
let client = MockClient::new(simple_text_events());
|
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 == "WorkerRemove"));
|
||||||
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -534,6 +534,9 @@ pub struct WorkerSpawnRequest {
|
|||||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||||
|
/// 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.
|
/// Backend-owned feature enablement; client input cannot set it.
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub resolved_worker_observation_enabled: bool,
|
pub resolved_worker_observation_enabled: bool,
|
||||||
@@ -5353,6 +5356,7 @@ fn runtime_create_worker_request(
|
|||||||
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
||||||
workspace_api: Some(workspace_api),
|
workspace_api: Some(workspace_api),
|
||||||
memory_settings: request.resolved_memory_settings.clone(),
|
memory_settings: request.resolved_memory_settings.clone(),
|
||||||
|
subjektiv_attached: request.resolved_subjektiv_attached,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7075,6 +7079,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7363,6 +7368,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7465,6 +7471,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -7505,6 +7512,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: Some(test_workspace_api()),
|
resolved_workspace_api: Some(test_workspace_api()),
|
||||||
resolved_memory_settings: Some(test_memory_settings()),
|
resolved_memory_settings: Some(test_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fn create_request(name: &str) -> CreateWorkerRequest {
|
|||||||
settings_revision: 1,
|
settings_revision: 1,
|
||||||
language: "English".to_string(),
|
language: "English".to_string(),
|
||||||
}),
|
}),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3410,6 +3410,7 @@ impl WorkspaceApi {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: Some(control_operation),
|
resolved_control_operation: Some(control_operation),
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -5977,6 +5978,7 @@ fn worker_spawn_request_from_api(
|
|||||||
resolved_config_bundle: None,
|
resolved_config_bundle: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_worker_observation_enabled: false,
|
resolved_worker_observation_enabled: false,
|
||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_control_operation: None,
|
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(
|
async fn scoped_list_subjektiv_subjects(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -16996,9 +17006,45 @@ async fn scoped_list_subjektiv_subjects(
|
|||||||
))
|
))
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
let cursor = query
|
||||||
|
.cursor
|
||||||
|
.as_deref()
|
||||||
|
.map(|cursor| decode_subjektiv_cursor::<SubjektivSubjectCursor>("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)?
|
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)?;
|
.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 {
|
Ok(Json(server_api::SubjektivSubjectListResponse {
|
||||||
limit,
|
limit,
|
||||||
items: page
|
items: page
|
||||||
@@ -17006,6 +17052,7 @@ async fn scoped_list_subjektiv_subjects(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|subject| subjektiv_subject_response(&api, subject))
|
.map(|subject| subjektiv_subject_response(&api, subject))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
next_cursor,
|
||||||
has_more: page.has_more,
|
has_more: page.has_more,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -17179,7 +17226,7 @@ async fn scoped_start_subjektiv_subject_worker(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?);
|
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(
|
async fn scoped_stage_subjektiv_candidate(
|
||||||
@@ -20190,6 +20237,7 @@ fn start_subjektiv_staging_consolidation(
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: true,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -20359,6 +20407,7 @@ fn start_memory_staging_consolidation(
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -21480,8 +21529,13 @@ async fn spawn_known_worker(
|
|||||||
operation_id: scoped_worker_control_operation_id(&controller, &operation_id),
|
operation_id: scoped_worker_control_operation_id(&controller, &operation_id),
|
||||||
input_fingerprint,
|
input_fingerprint,
|
||||||
});
|
});
|
||||||
let response =
|
let response = create_workspace_worker_inner(
|
||||||
create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation)
|
api.clone(),
|
||||||
|
headers,
|
||||||
|
request,
|
||||||
|
resolved_control_operation,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Err(error) = api
|
if let Err(error) = api
|
||||||
.store
|
.store
|
||||||
@@ -21890,6 +21944,7 @@ async fn scoped_start_workspace_orchestrator(
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -27040,7 +27095,9 @@ fn validate_caller_worker_singleton_key(singleton_key: Option<&str>) -> Result<(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
crate::store::validate_worker_singleton_key(singleton_key)?;
|
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!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Worker singleton key {singleton_key:?} is reserved for a Backend-managed Worker"
|
"Worker singleton key {singleton_key:?} is reserved for a Backend-managed Worker"
|
||||||
)));
|
)));
|
||||||
@@ -27053,7 +27110,7 @@ async fn create_workspace_worker(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateWorkspaceWorkerRequest>,
|
Json(request): Json<CreateWorkspaceWorkerRequest>,
|
||||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
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(
|
async fn create_workspace_worker_inner(
|
||||||
@@ -27061,6 +27118,7 @@ async fn create_workspace_worker_inner(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
request: CreateWorkspaceWorkerRequest,
|
request: CreateWorkspaceWorkerRequest,
|
||||||
resolved_control_operation: Option<WorkerControlOperation>,
|
resolved_control_operation: Option<WorkerControlOperation>,
|
||||||
|
resolved_subjektiv_attached: bool,
|
||||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
let CreateWorkspaceWorkerRequest {
|
let CreateWorkspaceWorkerRequest {
|
||||||
runtime_id,
|
runtime_id,
|
||||||
@@ -27072,7 +27130,21 @@ async fn create_workspace_worker_inner(
|
|||||||
workdir_attachments,
|
workdir_attachments,
|
||||||
control_operation_id: _,
|
control_operation_id: _,
|
||||||
} = request;
|
} = request;
|
||||||
|
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())?;
|
validate_caller_worker_singleton_key(singleton_key.as_deref())?;
|
||||||
|
}
|
||||||
let config_state = api
|
let config_state = api
|
||||||
.config_store
|
.config_store
|
||||||
.load_workspace_config(&api.config.workspace_id)?
|
.load_workspace_config(&api.config.workspace_id)?
|
||||||
@@ -27167,6 +27239,7 @@ async fn create_workspace_worker_inner(
|
|||||||
resolved_control_operation,
|
resolved_control_operation,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached,
|
||||||
};
|
};
|
||||||
validate_ticket_assignment_spawn(&api, &runtime_id, &request)?;
|
validate_ticket_assignment_spawn(&api, &runtime_id, &request)?;
|
||||||
let assignment = request.ticket_assignment.clone();
|
let assignment = request.ticket_assignment.clone();
|
||||||
@@ -35967,6 +36040,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
@@ -36223,6 +36297,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37607,7 +37682,7 @@ mod tests {
|
|||||||
let request = || CreateWorkspaceWorkerRequest {
|
let request = || CreateWorkspaceWorkerRequest {
|
||||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
display_name: "Subject Worker".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()),
|
profile: Some("builtin:companion".to_string()),
|
||||||
ticket_assignment: None,
|
ticket_assignment: None,
|
||||||
initial_submit: Vec::new(),
|
initial_submit: Vec::new(),
|
||||||
@@ -37621,7 +37696,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
created.worker.singleton_key.as_deref(),
|
created.worker.singleton_key.as_deref(),
|
||||||
Some("subjektiv:subject-42")
|
Some("custom:subject-42")
|
||||||
);
|
);
|
||||||
let Json(duplicate) =
|
let Json(duplicate) =
|
||||||
create_workspace_worker(State(api.clone()), HeaderMap::new(), Json(request()))
|
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!(restored.state, server_api::WorkerRestoreState::Rejected);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
api.store
|
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()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.worker,
|
.worker,
|
||||||
@@ -38081,6 +38156,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -38441,6 +38517,126 @@ mod tests {
|
|||||||
assert_eq!(repeated.staging_resolutions_deleted, 0);
|
assert_eq!(repeated.staging_resolutions_deleted, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subjektiv_browser_routes_enforce_router_auth_scope_and_owner_reset() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||||
|
let non_owner_token = seed_test_api_token(api.store.as_ref(), "subjektiv-non-owner");
|
||||||
|
let store = open_subjektiv_store(&api).unwrap();
|
||||||
|
let subject = store
|
||||||
|
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let memory = store
|
||||||
|
.create_memory(
|
||||||
|
&subject.id,
|
||||||
|
crate::subjektiv::MemoryDraft::active(
|
||||||
|
memory::extract::CandidateKind::Lesson,
|
||||||
|
"Router contract",
|
||||||
|
"Browser routes cross the generated Axum boundary.",
|
||||||
|
"Proves route wiring",
|
||||||
|
"fixture",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
api.store
|
||||||
|
.upsert_memory_document(&crate::store::MemoryDocumentRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
body_md: "# Legacy reset fixture\n".to_string(),
|
||||||
|
created_at: "2026-10-02T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-10-02T00:00:00Z".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let app = build_router(api.clone());
|
||||||
|
let subject_root = format!(
|
||||||
|
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}",
|
||||||
|
subject.id
|
||||||
|
);
|
||||||
|
let memory_root = format!("{subject_root}/memories/{}", memory.id);
|
||||||
|
|
||||||
|
request_json(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"),
|
||||||
|
None,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let subjects = request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects?limit=1"),
|
||||||
|
None,
|
||||||
|
&owner_token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(subjects["items"][0]["id"], subject.id);
|
||||||
|
for uri in [
|
||||||
|
subject_root.clone(),
|
||||||
|
format!("{subject_root}/surface"),
|
||||||
|
format!("{subject_root}/memories?limit=1"),
|
||||||
|
memory_root.clone(),
|
||||||
|
format!("{memory_root}/revisions?limit=1"),
|
||||||
|
] {
|
||||||
|
request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
&uri,
|
||||||
|
None,
|
||||||
|
&owner_token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
"/api/w/another-workspace/subjektiv/subjects?limit=1",
|
||||||
|
None,
|
||||||
|
&owner_token,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
request_json_authenticated(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
|
||||||
|
})),
|
||||||
|
&non_owner_token,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_memory_document(TEST_WORKSPACE_ID)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
let reset = request_json_authenticated(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/reset"),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"confirmation": format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
|
||||||
|
})),
|
||||||
|
&owner_token,
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(reset["documents_deleted"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.scoped_memory(&subject.id, &memory.id)
|
||||||
|
.unwrap()
|
||||||
|
.revision,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -38459,7 +38655,10 @@ mod tests {
|
|||||||
AxumPath(ScopedWorkspacePath {
|
AxumPath(ScopedWorkspacePath {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
}),
|
}),
|
||||||
Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }),
|
Query(server_api::SubjektivSubjectListQuery {
|
||||||
|
limit: Some(0),
|
||||||
|
cursor: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
@@ -38473,12 +38672,31 @@ mod tests {
|
|||||||
AxumPath(ScopedWorkspacePath {
|
AxumPath(ScopedWorkspacePath {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
}),
|
}),
|
||||||
Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }),
|
Query(server_api::SubjektivSubjectListQuery {
|
||||||
|
limit: Some(1),
|
||||||
|
cursor: None,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(subjects.items.len(), 1);
|
assert_eq!(subjects.items.len(), 1);
|
||||||
assert!(subjects.has_more);
|
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(
|
let missing = scoped_get_subjektiv_subject(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -38744,6 +38962,32 @@ mod tests {
|
|||||||
created.worker.singleton_key.as_deref(),
|
created.worker.singleton_key.as_deref(),
|
||||||
Some(format!("subjektiv:{}", subject.id).as_str())
|
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 {
|
let context = server_api::ServerRequestContext {
|
||||||
actor: None,
|
actor: None,
|
||||||
worker_source: None,
|
worker_source: None,
|
||||||
@@ -39795,6 +40039,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: Some(WorkerControlOperation {
|
resolved_control_operation: Some(WorkerControlOperation {
|
||||||
operation_id: operation_id.to_string(),
|
operation_id: operation_id.to_string(),
|
||||||
input_fingerprint: format!("sha256:{operation_id}"),
|
input_fingerprint: format!("sha256:{operation_id}"),
|
||||||
@@ -40074,6 +40319,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -41294,6 +41540,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -42199,6 +42446,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -44442,6 +44690,7 @@ mod tests {
|
|||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -44746,6 +44995,7 @@ mod tests {
|
|||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -45489,6 +45739,7 @@ mod tests {
|
|||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -45591,6 +45842,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)),
|
resolved_workspace_api: Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
};
|
};
|
||||||
let source_worker = api
|
let source_worker = api
|
||||||
@@ -46209,6 +46461,7 @@ mod tests {
|
|||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -46276,6 +46529,7 @@ mod tests {
|
|||||||
EMBEDDED_WORKER_RUNTIME_ID,
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -47666,6 +47920,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -47725,6 +47980,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -47838,6 +48094,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -49776,6 +50033,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -50880,6 +51138,7 @@ mod tests {
|
|||||||
worker_observation_grants: Vec::new(),
|
worker_observation_grants: Vec::new(),
|
||||||
workspace_api: None,
|
workspace_api: None,
|
||||||
memory_settings: Some(memory_settings),
|
memory_settings: Some(memory_settings),
|
||||||
|
subjektiv_attached: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52070,6 +52329,7 @@ mod tests {
|
|||||||
control_operation_id: None,
|
control_operation_id: None,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -52855,6 +53115,7 @@ mod tests {
|
|||||||
"embedded-worker-runtime",
|
"embedded-worker-runtime",
|
||||||
)),
|
)),
|
||||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -53508,6 +53769,7 @@ mod tests {
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
};
|
};
|
||||||
let spawned = api
|
let spawned = api
|
||||||
@@ -54370,6 +54632,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
resolved_worker_observation_grants: Vec::new(),
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18872,6 +18872,7 @@ mod tests {
|
|||||||
resolved_control_operation: None,
|
resolved_control_operation: None,
|
||||||
resolved_workspace_api: None,
|
resolved_workspace_api: None,
|
||||||
resolved_memory_settings: None,
|
resolved_memory_settings: None,
|
||||||
|
resolved_subjektiv_attached: false,
|
||||||
};
|
};
|
||||||
let stable_fingerprint =
|
let stable_fingerprint =
|
||||||
crate::hosts::worker_spawn_create_fingerprint(&request(Some("src"))).unwrap();
|
crate::hosts::worker_spawn_create_fingerprint(&request(Some("src"))).unwrap();
|
||||||
|
|||||||
@@ -1091,9 +1091,21 @@ impl SubjektivStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists one deterministic bounded page of subjects. Ordering uses immutable
|
/// Lists the first deterministic bounded page of subjects. Ordering uses
|
||||||
/// creation time with the host-issued subject id as a total-order tie breaker.
|
/// immutable creation time with the host-issued subject id as a total-order
|
||||||
|
/// tie breaker.
|
||||||
pub fn list_subjects(&self, limit: usize) -> Result<SubjectPage> {
|
pub fn list_subjects(&self, limit: usize) -> Result<SubjectPage> {
|
||||||
|
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<SubjectPage> {
|
||||||
if !(1..=100).contains(&limit) {
|
if !(1..=100).contains(&limit) {
|
||||||
return Err(SubjektivError::InvalidRecord(
|
return Err(SubjektivError::InvalidRecord(
|
||||||
"subject list limit must be between 1 and 100".to_string(),
|
"subject list limit must be between 1 and 100".to_string(),
|
||||||
@@ -1101,17 +1113,33 @@ impl SubjektivStore {
|
|||||||
}
|
}
|
||||||
let query_limit = limit.saturating_add(1);
|
let query_limit = limit.saturating_add(1);
|
||||||
self.database.try_with_connection(|connection| {
|
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
|
"SELECT record_json FROM subjects
|
||||||
ORDER BY created_at ASC, subject_id ASC
|
ORDER BY created_at ASC, subject_id ASC
|
||||||
LIMIT ?1",
|
LIMIT ?1"
|
||||||
)?;
|
};
|
||||||
let rows = statement
|
let mut statement = connection.prepare(sql)?;
|
||||||
.query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?;
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
if let Some((created_at, subject_id)) = after {
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![created_at, subject_id, to_i64(query_limit as u64)?],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
items.push(parse_subject(&row?)?);
|
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;
|
let has_more = items.len() > limit;
|
||||||
if has_more {
|
if has_more {
|
||||||
items.truncate(limit);
|
items.truncate(limit);
|
||||||
@@ -3559,6 +3587,12 @@ mod tests {
|
|||||||
let page = store.list_subjects(2).unwrap();
|
let page = store.list_subjects(2).unwrap();
|
||||||
assert_eq!(page.items, expected[..2]);
|
assert_eq!(page.items, expected[..2]);
|
||||||
assert!(page.has_more);
|
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(0).is_err());
|
||||||
assert!(store.list_subjects(101).is_err());
|
assert!(store.list_subjects(101).is_err());
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:<subject-id>`) or dedicated subject consolidator receives the trusted settings snapshot that activates subjektiv tools, resident load, and extraction. The snapshot is not subject identity and cannot be authored by a Profile, Browser, or model; Reviewer children never inherit the parent's attachment or run independent extraction.
|
||||||
|
|
||||||
## Session, Memory, and correction semantics
|
## Session, Memory, and correction semantics
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -9325,6 +9325,12 @@
|
|||||||
"SubjektivSubjectListQuery": {
|
"SubjektivSubjectListQuery": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cursor": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
"format": "uint",
|
"format": "uint",
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
@@ -9354,6 +9360,12 @@
|
|||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"next_cursor": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -14738,7 +14750,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3"
|
"x-yoi-source-digest": "sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const staleSubjectId = "stale-subject";
|
|||||||
const failedSubjectId = "failed-subject";
|
const failedSubjectId = "failed-subject";
|
||||||
const ungeneratedSubjectId = "ungenerated-subject";
|
const ungeneratedSubjectId = "ungenerated-subject";
|
||||||
const errorSubjectId = "error-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(
|
const paragraphs = Array.from(
|
||||||
{ length: sectionCount },
|
{ length: sectionCount },
|
||||||
@@ -64,6 +68,7 @@ const subjects = [
|
|||||||
subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0),
|
subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0),
|
||||||
subject(errorSubjectId, "Unavailable subject", "retired", 3),
|
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) {
|
function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) {
|
||||||
return {
|
return {
|
||||||
@@ -235,11 +240,8 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function revisions(memoryId: string) {
|
function revisions(memoryId: string, cursor: string | null) {
|
||||||
return {
|
const all = [
|
||||||
memory_id: memoryId,
|
|
||||||
current_revision: 3,
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
revision: 3,
|
revision: 3,
|
||||||
kind: "decision",
|
kind: "decision",
|
||||||
@@ -264,8 +266,14 @@ function revisions(memoryId: string) {
|
|||||||
change_reason: "Retracted the legacy shape.",
|
change_reason: "Retracted the legacy shape.",
|
||||||
updated_at: "2026-01-01T03:04:05Z",
|
updated_at: "2026-01-01T03:04:05Z",
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
has_more: false,
|
const continued = cursor === revisionPageCursor;
|
||||||
|
return {
|
||||||
|
memory_id: memoryId,
|
||||||
|
current_revision: 3,
|
||||||
|
items: continued ? all.slice(2) : all.slice(0, 2),
|
||||||
|
...(continued ? {} : { next_cursor: revisionPageCursor }),
|
||||||
|
has_more: !continued,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +361,13 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
|
|
||||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||||
if (url.pathname === subjectsPath) {
|
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}/`)) {
|
if (url.pathname.startsWith(`${subjectsPath}/`)) {
|
||||||
const suffix = url.pathname.slice(subjectsPath.length + 1);
|
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 === 1) return json(foundSubject);
|
||||||
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
||||||
if (parts.length === 2 && parts[1] === "memories") {
|
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") {
|
if (parts.length >= 3 && parts[1] === "memories") {
|
||||||
const memoryId = parts[2];
|
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) {
|
if (parts.length === 3) {
|
||||||
const requested = url.searchParams.get("revision");
|
const requested = url.searchParams.get("revision");
|
||||||
return json(memoryDetail(memoryId, requested === null ? null : Number(requested)));
|
return json(memoryDetail(memoryId, requested === null ? null : Number(requested)));
|
||||||
|
|||||||
@@ -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.getByRole("heading", { name: "Revision history" }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).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 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.locator('a[href^="javascript:"]').count(), 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await detailPage.getByRole("article", { name: "Committed Memory body" }).locator(
|
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 assertMainOwnsVerticalScroll(detailPage);
|
||||||
await detailContext.close();
|
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 stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||||
const stateCases = [
|
const stateCases = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -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 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<SubjektivSubjectResponse>, has_more: boolean, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256: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
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256: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
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// 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
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -228,7 +228,9 @@ function parseStagingEvidence(value: unknown): MemoryStagingEvidence {
|
|||||||
summary: nullableString(record, "summary"),
|
summary: nullableString(record, "summary"),
|
||||||
};
|
};
|
||||||
if ("origin" in record) {
|
if ("origin" in record) {
|
||||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
result.origin = record.origin === null
|
||||||
|
? null
|
||||||
|
: parseEvidenceOrigin(record.origin);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -259,7 +261,9 @@ function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef {
|
|||||||
summary: nullableString(record, "summary"),
|
summary: nullableString(record, "summary"),
|
||||||
};
|
};
|
||||||
if ("origin" in record) {
|
if ("origin" in record) {
|
||||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
result.origin = record.origin === null
|
||||||
|
? null
|
||||||
|
: parseEvidenceOrigin(record.origin);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -350,7 +354,9 @@ export function parseSubjektivSubjectResponse(
|
|||||||
updated_at: requiredString(record, "updated_at"),
|
updated_at: requiredString(record, "updated_at"),
|
||||||
};
|
};
|
||||||
if ("current_worker" in record) {
|
if ("current_worker" in record) {
|
||||||
result.current_worker = record.current_worker === null ? null : parseWorkerLaunchWorkerSummary(
|
result.current_worker = record.current_worker === null
|
||||||
|
? null
|
||||||
|
: parseWorkerLaunchWorkerSummary(
|
||||||
record.current_worker,
|
record.current_worker,
|
||||||
"Subject current worker",
|
"Subject current worker",
|
||||||
);
|
);
|
||||||
@@ -363,8 +369,9 @@ export function parseSubjektivSubjectListResponse(
|
|||||||
): SubjektivSubjectListResponse {
|
): SubjektivSubjectListResponse {
|
||||||
const record = strictRecord(
|
const record = strictRecord(
|
||||||
value,
|
value,
|
||||||
["limit", "items", "has_more"],
|
["limit", "items", "next_cursor", "has_more"],
|
||||||
"Subject list response",
|
"Subject list response",
|
||||||
|
["next_cursor"],
|
||||||
);
|
);
|
||||||
const limit = positiveInteger(record.limit, "limit");
|
const limit = positiveInteger(record.limit, "limit");
|
||||||
if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound");
|
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");
|
if (items.length > limit) invalid("items exceeds the declared Subject limit");
|
||||||
assertUnique(items.map((item) => item.id), "Subject ids");
|
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,
|
limit,
|
||||||
items,
|
items,
|
||||||
has_more: requiredBoolean(record, "has_more"),
|
has_more: hasMore,
|
||||||
};
|
};
|
||||||
|
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSubjektivResidentSurfaceResponse(
|
export function parseSubjektivResidentSurfaceResponse(
|
||||||
@@ -562,7 +574,9 @@ export function parseSubjektivMemoryReadResponse(
|
|||||||
).map(parseMemoryEvidenceCandidate);
|
).map(parseMemoryEvidenceCandidate);
|
||||||
if (
|
if (
|
||||||
sourceCandidateIds.length !== sourceCandidates.length ||
|
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");
|
invalid("source candidate ids do not match source candidates");
|
||||||
}
|
}
|
||||||
@@ -690,7 +704,9 @@ function parseMemoryEvidence(value: unknown): SubjektivMemoryEvidence {
|
|||||||
: parseEntryRange(record.entry_range, "entry_range");
|
: parseEntryRange(record.entry_range, "entry_range");
|
||||||
}
|
}
|
||||||
if ("origin" in record) {
|
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 ("excerpt" in record) result.excerpt = nullableString(record, "excerpt");
|
||||||
if ("summary" in record) result.summary = nullableString(record, "summary");
|
if ("summary" in record) result.summary = nullableString(record, "summary");
|
||||||
@@ -735,7 +751,9 @@ function parseMemorySourceEvidenceRef(
|
|||||||
: parseEntryRange(record.entry_range, "entry_range");
|
: parseEntryRange(record.entry_range, "entry_range");
|
||||||
}
|
}
|
||||||
if ("origin" in record) {
|
if ("origin" in record) {
|
||||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
result.origin = record.origin === null
|
||||||
|
? null
|
||||||
|
: parseEvidenceOrigin(record.origin);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
function subjectHref(subjectId: string): string {
|
function subjectHref(subjectId: string): string {
|
||||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
<div class="memory-state" role="status" data-memory-state="empty">
|
<div class="memory-state" role="status" data-memory-state="empty">
|
||||||
<strong>No Memory subjects.</strong>
|
<strong>No Memory subjects.</strong>
|
||||||
<p>Subjects will appear here after they are created for this Workspace.</p>
|
<p>Subjects will appear here after they are created for this Workspace.</p>
|
||||||
|
{#if data.cursor}<p><a href={pageHref()}>Return to the first page</a></p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="subject-list" aria-label="Memory subjects">
|
<div class="subject-list" aria-label="Memory subjects">
|
||||||
@@ -51,9 +57,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if data.subjects.data.has_more}
|
<nav class="memory-pagination" aria-label="Subject pages">
|
||||||
<p class="memory-note">Only the first {data.subjects.data.limit} subjects are shown.</p>
|
{#if data.cursor}<a href={pageHref()}>First page</a>{/if}
|
||||||
|
{#if data.subjects.data.has_more && data.subjects.data.next_cursor}
|
||||||
|
<a href={pageHref(data.subjects.data.next_cursor)}>Next page →</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if data.subjects.error}
|
{:else if data.subjects.error}
|
||||||
<div class="memory-state is-error" role="alert" data-memory-state="error">
|
<div class="memory-state is-error" role="alert" data-memory-state="error">
|
||||||
@@ -203,8 +212,15 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.memory-state p,
|
.memory-pagination {
|
||||||
.memory-note {
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-state p {
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,25 @@ import {
|
|||||||
} from "$lib/workspace/memory/api";
|
} from "$lib/workspace/memory/api";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
const MAX_CURSOR_BYTES = 16_384;
|
||||||
|
|
||||||
|
function boundedCursor(value: string | null): string | null {
|
||||||
|
if (value === null || value.length === 0) return null;
|
||||||
|
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||||
|
const cursor = boundedCursor(url.searchParams.get("cursor"));
|
||||||
|
const query = new URLSearchParams({ limit: "100" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
|
cursor,
|
||||||
subjects: await loadJson(
|
subjects: await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
|
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?${query}`,
|
||||||
undefined,
|
undefined,
|
||||||
parseSubjektivSubjectListResponse,
|
parseSubjektivSubjectListResponse,
|
||||||
MEMORY_API_LOAD_POLICY,
|
MEMORY_API_LOAD_POLICY,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memoryPageHref(cursor?: string | null): string {
|
||||||
|
const path = workspaceRoute(
|
||||||
|
data.workspaceId,
|
||||||
|
`/memory/${encodeURIComponent(data.subjectId)}`,
|
||||||
|
);
|
||||||
|
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
function stateLabel(state: SubjektivMemoryState): string {
|
function stateLabel(state: SubjektivMemoryState): string {
|
||||||
return state[0].toUpperCase() + state.slice(1);
|
return state[0].toUpperCase() + state.slice(1);
|
||||||
}
|
}
|
||||||
@@ -152,6 +160,7 @@
|
|||||||
<div class="memory-state" role="status" data-memories-empty>
|
<div class="memory-state" role="status" data-memories-empty>
|
||||||
<strong>No committed Memories.</strong>
|
<strong>No committed Memories.</strong>
|
||||||
<p>This subject has no current Memory revisions.</p>
|
<p>This subject has no current Memory revisions.</p>
|
||||||
|
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="memory-list" aria-label="Current committed Memories">
|
<div class="memory-list" aria-label="Current committed Memories">
|
||||||
@@ -173,9 +182,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if data.memories.data.has_more}
|
<nav class="memory-pagination" aria-label="Current Memory pages">
|
||||||
<p class="memory-note">More current Memories are available after this bounded page.</p>
|
{#if data.cursor}<a href={memoryPageHref()}>First page</a>{/if}
|
||||||
|
{#if data.memories.data.has_more && data.memories.data.next_cursor}
|
||||||
|
<a href={memoryPageHref(data.memories.data.next_cursor)}>Next page →</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if data.memories.error}
|
{:else if data.memories.error}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
@@ -388,8 +400,7 @@
|
|||||||
font-size: var(--font-size-body);
|
font-size: var(--font-size-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.memory-row p,
|
.memory-row p {
|
||||||
.memory-note {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -417,6 +428,14 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.memory-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
.memory-state strong {
|
.memory-state strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ import {
|
|||||||
} from "$lib/workspace/memory/api";
|
} from "$lib/workspace/memory/api";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
const MAX_CURSOR_BYTES = 16_384;
|
||||||
|
|
||||||
|
function boundedCursor(value: string | null): string | null {
|
||||||
|
if (value === null || value.length === 0) return null;
|
||||||
|
return new TextEncoder().encode(value).byteLength <= MAX_CURSOR_BYTES
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||||
const subjectId = params.subjectId;
|
const subjectId = params.subjectId;
|
||||||
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
||||||
|
const cursor = boundedCursor(url.searchParams.get("cursor"));
|
||||||
|
const memoryQuery = new URLSearchParams({ limit: "100" });
|
||||||
|
if (cursor) memoryQuery.set("cursor", cursor);
|
||||||
const [subject, surface, memories] = await Promise.all([
|
const [subject, surface, memories] = await Promise.all([
|
||||||
loadJson(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -27,7 +39,9 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
),
|
),
|
||||||
loadJson(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)}?limit=100`,
|
`${
|
||||||
|
workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)
|
||||||
|
}?${memoryQuery}`,
|
||||||
undefined,
|
undefined,
|
||||||
parseSubjektivMemoryQueryResponse,
|
parseSubjektivMemoryQueryResponse,
|
||||||
MEMORY_API_LOAD_POLICY,
|
MEMORY_API_LOAD_POLICY,
|
||||||
@@ -37,6 +51,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
|||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
subjectId,
|
subjectId,
|
||||||
|
cursor,
|
||||||
subject,
|
subject,
|
||||||
surface,
|
surface,
|
||||||
memories,
|
memories,
|
||||||
|
|||||||
+25
-1
@@ -28,6 +28,14 @@
|
|||||||
return `${memoryPath()}?revision=${revision}`;
|
return `${memoryPath()}?revision=${revision}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revisionPageHref(cursor?: string | null): string {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (memory) query.set('revision', String(memory.revision));
|
||||||
|
if (cursor) query.set('revision_cursor', cursor);
|
||||||
|
const encoded = query.toString();
|
||||||
|
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
|
||||||
|
}
|
||||||
|
|
||||||
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
||||||
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
||||||
}
|
}
|
||||||
@@ -221,7 +229,10 @@
|
|||||||
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
|
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
|
||||||
{#if data.revisions.data}
|
{#if data.revisions.data}
|
||||||
{#if revisions.length === 0}
|
{#if revisions.length === 0}
|
||||||
|
<div>
|
||||||
<p class="empty-copy">No revision history is available.</p>
|
<p class="empty-copy">No revision history is available.</p>
|
||||||
|
{#if data.revisionCursor}<p><a href={revisionPageHref()}>Return to the first page</a></p>{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ol class="revision-list">
|
<ol class="revision-list">
|
||||||
{#each revisions as revision (revision.revision)}
|
{#each revisions as revision (revision.revision)}
|
||||||
@@ -234,7 +245,12 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{#if data.revisions.data.has_more}<p class="bounded-note">More immutable revisions are available after this bounded page.</p>{/if}
|
<nav class="memory-pagination" aria-label="Revision history pages">
|
||||||
|
{#if data.revisionCursor}<a href={revisionPageHref()}>First page</a>{/if}
|
||||||
|
{#if data.revisions.data.has_more && data.revisions.data.next_cursor}
|
||||||
|
<a href={revisionPageHref(data.revisions.data.next_cursor)}>Next page →</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if data.revisions.error}
|
{:else if data.revisions.error}
|
||||||
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
|
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
|
||||||
@@ -433,6 +449,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.memory-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
.candidate-list {
|
.candidate-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import {
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
|
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
|
||||||
|
const MAX_CURSOR_BYTES = 16_384;
|
||||||
|
|
||||||
|
function boundedCursor(
|
||||||
|
value: string | null,
|
||||||
|
maxBytes = MAX_CURSOR_BYTES,
|
||||||
|
): string | null {
|
||||||
|
if (value === null || value.length === 0) return null;
|
||||||
|
return new TextEncoder().encode(value).byteLength <= maxBytes ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function safeIntegerQuery(
|
function safeIntegerQuery(
|
||||||
source: URLSearchParams,
|
source: URLSearchParams,
|
||||||
@@ -39,13 +48,14 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
|||||||
const value = safeIntegerQuery(url.searchParams, key, positive);
|
const value = safeIntegerQuery(url.searchParams, key, positive);
|
||||||
if (value !== null) detailQuery.set(key, value);
|
if (value !== null) detailQuery.set(key, value);
|
||||||
}
|
}
|
||||||
const evidenceCursor = url.searchParams.get("evidence_cursor");
|
const evidenceCursor = boundedCursor(
|
||||||
if (
|
url.searchParams.get("evidence_cursor"),
|
||||||
evidenceCursor !== null && evidenceCursor.length > 0 &&
|
512,
|
||||||
new TextEncoder().encode(evidenceCursor).byteLength <= 512
|
);
|
||||||
) {
|
if (evidenceCursor) detailQuery.set("evidence_cursor", evidenceCursor);
|
||||||
detailQuery.set("evidence_cursor", evidenceCursor);
|
const revisionCursor = boundedCursor(url.searchParams.get("revision_cursor"));
|
||||||
}
|
const revisionQuery = new URLSearchParams({ limit: "100" });
|
||||||
|
if (revisionCursor) revisionQuery.set("cursor", revisionCursor);
|
||||||
|
|
||||||
const [subject, memory, revisions] = await Promise.all([
|
const [subject, memory, revisions] = await Promise.all([
|
||||||
loadJson(
|
loadJson(
|
||||||
@@ -64,7 +74,9 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
|||||||
),
|
),
|
||||||
loadJson(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)}?limit=100`,
|
`${
|
||||||
|
workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)
|
||||||
|
}?${revisionQuery}`,
|
||||||
undefined,
|
undefined,
|
||||||
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
||||||
MEMORY_API_LOAD_POLICY,
|
MEMORY_API_LOAD_POLICY,
|
||||||
@@ -75,6 +87,7 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
|
|||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
subjectId,
|
subjectId,
|
||||||
memoryId,
|
memoryId,
|
||||||
|
revisionCursor,
|
||||||
subject,
|
subject,
|
||||||
memory,
|
memory,
|
||||||
revisions,
|
revisions,
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ test("renders the subject index as the Memory product entry", () => {
|
|||||||
render(SubjectIndexPage, {
|
render(SubjectIndexPage, {
|
||||||
data: {
|
data: {
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjects: result({ limit: 100, items: [subject()], has_more: false }),
|
cursor: "subject-current",
|
||||||
|
subjects: result({
|
||||||
|
limit: 100,
|
||||||
|
items: [subject()],
|
||||||
|
next_cursor: "subject-next",
|
||||||
|
has_more: true,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
@@ -37,6 +43,10 @@ test("renders the subject index as the Memory product entry", () => {
|
|||||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||||
|
.toBe("/w/workspace-1/memory");
|
||||||
|
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||||
|
.toContain("cursor=subject-next");
|
||||||
expect(screen.queryByText("Staging")).toBeNull();
|
expect(screen.queryByText("Staging")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
data: {
|
data: {
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
|
cursor: "memory-current",
|
||||||
subject: result(subject()),
|
subject: result(subject()),
|
||||||
surface: result({
|
surface: result({
|
||||||
subject_id: "subject-1",
|
subject_id: "subject-1",
|
||||||
@@ -87,7 +98,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
updated_at: "2026-01-02T03:04:05Z",
|
updated_at: "2026-01-02T03:04:05Z",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
has_more: false,
|
next_cursor: "memory-next",
|
||||||
|
has_more: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
@@ -101,8 +113,12 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("resolved", { exact: true })).not.toBeNull();
|
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
||||||
expect(screen.getByText("retracted", { exact: true })).not.toBeNull();
|
expect(screen.getByText("Retracted", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getByRole("navigation", { name: "Current Memory pages" }))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||||
|
.toContain("cursor=memory-next");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||||
@@ -177,6 +193,7 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
|||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
memoryId: "memory-1",
|
memoryId: "memory-1",
|
||||||
|
revisionCursor: "revision-current",
|
||||||
subject: result(subject()),
|
subject: result(subject()),
|
||||||
memory: result({
|
memory: result({
|
||||||
memory_id: "memory-1",
|
memory_id: "memory-1",
|
||||||
@@ -233,7 +250,8 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
|||||||
change_reason: "Resolved",
|
change_reason: "Resolved",
|
||||||
updated_at: "2026-01-02T00:00:00Z",
|
updated_at: "2026-01-02T00:00:00Z",
|
||||||
}],
|
}],
|
||||||
has_more: false,
|
next_cursor: "revision-next",
|
||||||
|
has_more: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
@@ -248,4 +266,8 @@ test("renders committed Memory detail, provenance, derivation, and immutable rev
|
|||||||
expect(screen.getByRole("heading", { name: "Revision history" })).not
|
expect(screen.getByRole("heading", { name: "Revision history" })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("Historical revision")).not.toBeNull();
|
expect(screen.getByText("Historical revision")).not.toBeNull();
|
||||||
|
expect(screen.getByRole("navigation", { name: "Revision history pages" }))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||||
|
.toContain("revision_cursor=revision-next");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
|||||||
() =>
|
() =>
|
||||||
parseSubjektivSubjectListResponse({
|
parseSubjektivSubjectListResponse({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
items: Array.from({ length: 101 }, (_, index) =>
|
||||||
|
subject(`subject-${index}`)),
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"bounded array",
|
||||||
@@ -170,6 +171,22 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
|||||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||||
const response = { limit: 100, items: [subject()], has_more: false };
|
const response = { limit: 100, items: [subject()], has_more: false };
|
||||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||||
|
const nextPage = {
|
||||||
|
limit: 1,
|
||||||
|
items: [subject()],
|
||||||
|
next_cursor: "subjektiv.subjects.next",
|
||||||
|
has_more: true,
|
||||||
|
};
|
||||||
|
assertEquals(parseSubjektivSubjectListResponse(nextPage), nextPage);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseSubjektivSubjectListResponse({
|
||||||
|
limit: 1,
|
||||||
|
items: [subject()],
|
||||||
|
has_more: true,
|
||||||
|
}),
|
||||||
|
"cursor does not match has_more",
|
||||||
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseSubjektivSubjectListResponse({
|
parseSubjektivSubjectListResponse({
|
||||||
@@ -278,7 +295,8 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
|||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseSubjektivMemoryQueryResponse({
|
parseSubjektivMemoryQueryResponse({
|
||||||
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
items: Array.from({ length: 101 }, (_, index) =>
|
||||||
|
queryItem(`memory-${index}`)),
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"bounded array",
|
||||||
|
|||||||
Reference in New Issue
Block a user