chore: merge develop into hare/develop

This commit is contained in:
2026-10-07 18:03:33 +09:00
117 changed files with 27801 additions and 10459 deletions
Generated
+59
View File
@@ -543,6 +543,13 @@ dependencies = [
"serde",
]
[[package]]
name = "builtin-source"
version = "0.1.0"
dependencies = [
"sha2 0.11.0",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -766,6 +773,7 @@ dependencies = [
name = "config-source"
version = "0.1.0"
dependencies = [
"builtin-source",
"decodal",
"decodal-language-service",
"decodal-language-tools",
@@ -1396,6 +1404,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "feature-storage"
version = "0.1.0"
dependencies = [
"rusqlite",
"tempfile",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -2339,6 +2357,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "job"
version = "0.1.0"
dependencies = [
"manifest",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2577,6 +2606,7 @@ version = "0.1.0"
dependencies = [
"agen",
"arc-swap",
"builtin-source",
"decodal",
"protocol",
"secrets",
@@ -4623,13 +4653,20 @@ dependencies = [
"agen",
"async-trait",
"client",
"feature-storage",
"fs4",
"futures",
"job",
"libc",
"manifest",
"memory",
"protocol",
"rusqlite",
"serde",
"serde_json",
"server-api",
"session-store",
"subjektiv",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -4706,6 +4743,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subjektiv"
version = "0.1.0"
dependencies = [
"chrono",
"feature-storage",
"manifest",
"memory",
"rusqlite",
"serde",
"serde_json",
"server-api",
"tempfile",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6432,6 +6486,7 @@ dependencies = [
"futures-util",
"http",
"include_dir",
"job",
"jsonschema",
"libc",
"manifest",
@@ -6569,6 +6624,7 @@ dependencies = [
"server-api",
"session-analytics",
"session-store",
"standalone",
"tempfile",
"ticket",
"tokio",
@@ -6613,8 +6669,10 @@ dependencies = [
"axum",
"chrono",
"config-source",
"feature-storage",
"flow",
"futures",
"job",
"manifest",
"memory",
"merge-request",
@@ -6631,6 +6689,7 @@ dependencies = [
"session-store",
"sha2 0.11.0",
"ssh-key",
"subjektiv",
"tempfile",
"thiserror 2.0.18",
"ticket",
+12
View File
@@ -8,6 +8,7 @@ members = [
"crates/api-macros-impl",
"crates/session-store",
"crates/standalone",
"crates/job",
"crates/secrets",
"crates/manifest",
"crates/mcp",
@@ -24,11 +25,14 @@ members = [
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/builtin-source",
"crates/config-source",
"crates/config-source-wasm",
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
@@ -43,6 +47,7 @@ default-members = [
"crates/api-macros-impl",
"crates/session-store",
"crates/standalone",
"crates/job",
"crates/secrets",
"crates/manifest",
"crates/mcp",
@@ -59,11 +64,14 @@ default-members = [
"crates/tools",
"crates/fs-operation",
"crates/flow",
"crates/builtin-source",
"crates/config-source",
"crates/config-source-wasm",
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
@@ -91,6 +99,8 @@ manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
feature-storage = { path = "crates/feature-storage" }
subjektiv = { path = "crates/subjektiv" }
merge-request = { path = "crates/merge-request" }
ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" }
@@ -103,10 +113,12 @@ session-metrics = { path = "crates/session-metrics" }
session-analytics = { path = "crates/session-analytics" }
session-store = { path = "crates/session-store" }
standalone = { path = "crates/standalone" }
job = { path = "crates/job" }
runtime-api = { path = "crates/runtime-api" }
server-api = { path = "crates/server-api" }
secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" }
builtin-source = { path = "crates/builtin-source" }
config-source = { path = "crates/config-source" }
fs-operation = { path = "crates/fs-operation" }
workdir = { path = "crates/workdir" }
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "builtin-source"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
sha2.workspace = true
+214
View File
@@ -0,0 +1,214 @@
//! Explicit compile-time public Decodal source catalog, shared by native and WASM hosts.
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileImport {
pub specifier: &'static str,
pub resolved_path: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileResource {
pub selector: Option<&'static str>,
pub path: &'static str,
pub source: &'static str,
pub description: &'static str,
pub imports: &'static [BuiltinProfileImport],
}
const BASE_PATH: &str = "profiles/base.dcdl";
pub const DEFAULT_PATH: &str = "profiles/default.dcdl";
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./base.dcdl",
resolved_path: BASE_PATH,
}];
const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./default.dcdl",
resolved_path: DEFAULT_PATH,
}];
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
BuiltinProfileResource {
selector: None,
path: BASE_PATH,
source: include_str!("../../../resources/profiles/base.dcdl"),
description: "Shared built-in Profile defaults.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some(BUILTIN_DEFAULT_PROFILE),
path: DEFAULT_PATH,
source: include_str!("../../../resources/profiles/default.dcdl"),
description: "Default Yoi coding profile.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some(BUILTIN_STANDALONE_PROFILE),
path: "profiles/standalone.dcdl",
source: include_str!("../../../resources/profiles/standalone.dcdl"),
description: "Standalone Yoi coding profile.",
imports: DEFAULT_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:standalone-subjektiv"),
path: "profiles/standalone-subjektiv.dcdl",
source: include_str!("../../../resources/profiles/standalone-subjektiv.dcdl"),
description: "Standalone with an explicitly connected local Subject.",
imports: &[BuiltinProfileImport {
specifier: "./standalone.dcdl",
resolved_path: "profiles/standalone.dcdl",
}],
},
BuiltinProfileResource {
selector: Some("builtin:standalone-subjektiv-consolidation"),
path: "profiles/standalone-subjektiv-consolidation.dcdl",
source: include_str!("../../../resources/profiles/standalone-subjektiv-consolidation.dcdl"),
description: "Delegated local Subject Job policy.",
imports: &[BuiltinProfileImport {
specifier: "./job.dcdl",
resolved_path: "profiles/job.dcdl",
}],
},
BuiltinProfileResource {
selector: Some("builtin:coder"),
path: "profiles/coder.dcdl",
source: include_str!("../../../resources/profiles/coder.dcdl"),
description: "Ticket implementation with direct Reviewer SubWorkers.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
source: include_str!("../../../resources/profiles/companion.dcdl"),
description: "General assistance with Workspace tools.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:intake"),
path: "profiles/intake.dcdl",
source: include_str!("../../../resources/profiles/intake.dcdl"),
description: "Read-only intake and planning.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:reviewer"),
path: "profiles/reviewer.dcdl",
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
description: "Independent review of a published Merge Request source.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:orchestrator"),
path: "profiles/orchestrator.dcdl",
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
description: "Workspace orchestration and Worker control.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:job"),
path: "profiles/job.dcdl",
source: include_str!("../../../resources/profiles/job.dcdl"),
description: "Result-only caller-selected Job policy.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some("builtin:backend-job"),
path: "profiles/backend-job.dcdl",
source: include_str!("../../../resources/profiles/backend-job.dcdl"),
description: "Backend-owned bounded Job Worker.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:memory-consolidation"),
path: "profiles/memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
description: "Internal Memory consolidation service.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:subjektiv-memory-consolidation"),
path: "profiles/subjektiv-memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"),
description: "Delegated subject Memory consolidation service.",
imports: BASE_IMPORT,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltinProfileCatalogSnapshot {
pub id: &'static str,
pub sources: BTreeMap<String, String>,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
}
impl BuiltinProfileCatalogSnapshot {
pub fn digest(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.id.as_bytes());
for (path, source) in &self.sources {
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
hasher.update((source.len() as u64).to_le_bytes());
hasher.update(source.as_bytes());
}
for (selector, path) in &self.entrypoints {
hasher.update((selector.len() as u64).to_le_bytes());
hasher.update(selector.as_bytes());
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
}
for (request, resolved_path) in &self.imports {
hasher.update((request.len() as u64).to_le_bytes());
hasher.update(request.as_bytes());
hasher.update((resolved_path.len() as u64).to_le_bytes());
hasher.update(resolved_path.as_bytes());
}
format!(
"sha256:{}",
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
}
}
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
let mut sources = BTreeMap::new();
let mut entrypoints = BTreeMap::new();
let mut imports = BTreeMap::new();
for resource in BUILTIN_PROFILE_RESOURCES {
sources.insert(resource.path.to_owned(), resource.source.to_owned());
for import in resource.imports {
imports.insert(
format!("{}\0{}", resource.path, import.specifier),
import.resolved_path.to_owned(),
);
}
if let Some(selector) = resource.selector {
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
}
}
BuiltinProfileCatalogSnapshot {
id: BUILTIN_PROFILE_CATALOG_ID,
sources,
entrypoints,
imports,
}
}
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
BUILTIN_PROFILE_RESOURCES
.iter()
.filter(|resource| resource.selector.is_some())
}
+41
View File
@@ -0,0 +1,41 @@
use builtin_source::{BUILTIN_PROFILE_RESOURCES, builtin_profile_catalog_snapshot};
use std::collections::BTreeSet;
#[test]
fn public_catalog_is_closed_and_explicit() {
let catalog = builtin_profile_catalog_snapshot();
assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len());
assert!(catalog.digest().starts_with("sha256:"));
for resource in BUILTIN_PROFILE_RESOURCES {
assert!(resource.path.starts_with("profiles/"));
assert!(resource.path.ends_with(".dcdl"));
assert_eq!(catalog.sources[resource.path], resource.source);
for import in resource.imports {
assert!(catalog.sources.contains_key(import.resolved_path));
assert!(
resource
.source
.contains(&format!("import {:?}", import.specifier))
);
}
}
assert!(!catalog.sources.contains_key("prompts/default.md"));
}
#[test]
fn catalog_import_metadata_has_no_cycles() {
fn visit(path: &str, visiting: &mut BTreeSet<String>) {
assert!(visiting.insert(path.to_string()), "catalog cycle at {path}");
let resource = BUILTIN_PROFILE_RESOURCES
.iter()
.find(|resource| resource.path == path)
.unwrap();
for import in resource.imports {
visit(import.resolved_path, visiting);
}
visiting.remove(path);
}
for resource in BUILTIN_PROFILE_RESOURCES {
visit(resource.path, &mut BTreeSet::new());
}
}
+116 -5
View File
@@ -98,14 +98,31 @@ pub fn complete_current(
let snapshot = session
.as_ref()
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
encode(complete_snapshot(
snapshot,
&entrypoint,
&source,
utf16_offset,
explicit,
)?)
})
}
fn complete_snapshot(
snapshot: &ConfigTreeSnapshot,
entrypoint: &VirtualPath,
source: &str,
utf16_offset: usize,
explicit: bool,
) -> Result<Option<WasmCompletionResult>, JsValue> {
let utf8_byte_offset = utf16_to_utf8_offset(source, utf16_offset)?;
let result = session_environment(snapshot.clone())
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
.complete_config(entrypoint, source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
let result = result
.map(|result| {
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
from: utf8_to_utf16_offset(&source, result.from)?,
from: utf8_to_utf16_offset(source, result.from)?,
items: result
.items
.into_iter()
@@ -119,8 +136,7 @@ pub fn complete_current(
})
})
.transpose()?;
encode(result)
})
Ok(result)
}
#[wasm_bindgen]
@@ -207,3 +223,98 @@ fn js_error(error: impl std::fmt::Display) -> JsValue {
#[allow(dead_code)]
fn _assert_serializable(_: EvaluationResult) {}
#[cfg(test)]
mod tests {
use super::*;
use config_source::{ConfigContentType, ConfigEntry};
fn snapshot() -> ConfigTreeSnapshot {
ConfigTreeSnapshot::from_entries(
7,
[
ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
"{}",
)
.unwrap(),
// A same-suffix workspace file must not be an unknown builtin fallback.
ConfigEntry::new(
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
ConfigContentType::Decodal,
"{}",
)
.unwrap(),
],
)
.unwrap()
}
#[test]
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
let snapshot = snapshot();
let source = "let 名 = 1; import \"$builtin/profiles/comp\"";
let cursor = source.encode_utf16().count() - 1;
let result = complete_snapshot(
&snapshot,
&VirtualPath::parse("main.dcdl").unwrap(),
source,
cursor,
true,
)
.unwrap()
.unwrap();
assert_eq!(result.from, "let 名 = 1; import \"".encode_utf16().count());
let item = result
.items
.iter()
.find(|item| item.label == "$builtin/profiles/companion.dcdl")
.unwrap();
assert_eq!(item.kind, "file");
assert_eq!(
item.detail.as_deref(),
Some("read-only builtin Decodal source")
);
assert_eq!(snapshot.entries.len(), 2);
assert!(
snapshot
.entries
.keys()
.all(|path| !path.as_str().starts_with("$builtin/"))
);
}
#[test]
fn wasm_analysis_boundary_reports_unknown_builtin_without_workspace_fallback() {
let snapshot = snapshot();
let path = VirtualPath::parse("main.dcdl").unwrap();
let diagnostics = session_environment(snapshot.clone())
.analyze(&path, Some("import \"$builtin/profiles/missing.dcdl\""));
assert!(!diagnostics.is_empty());
let diagnostic = diagnostics
.iter()
.find(|diagnostic| {
diagnostic
.message
.contains("unknown or non-public read-only builtin source")
})
.unwrap();
assert_eq!(diagnostic.path, path);
assert_eq!(diagnostic.revision, 7);
assert_eq!(diagnostic.tree_digest, snapshot.digest);
assert!(
diagnostic
.message
.contains("$builtin/profiles/missing.dcdl")
);
assert!(
session_environment(snapshot)
.analyze(
&VirtualPath::parse("main.dcdl").unwrap(),
Some("import \"./profiles/missing.dcdl\"")
)
.is_empty()
);
}
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
publish = false
[dependencies]
builtin-source.workspace = true
decodal.workspace = true
decodal-language-service.workspace = true
decodal-language-tools.workspace = true
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "feature-storage"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
rusqlite.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+28
View File
@@ -0,0 +1,28 @@
# feature-storage
Neutral Host-owned SQLite lifecycle manager for trusted Features. Owns placement,
WAL/connection settings, transaction serialization, migrations, snapshots,
restore/delete fencing, and shutdown. Features receive a managed
`FeatureDatabase` after registration. Worker/model input never selects a database
path or receives SQL access.
Use `FeatureStorage::new(trusted_root)`, `register(FeatureRegistration)`, and
`scope(host_issued_scope_id).open(&registration)`. `ScopedFeatureStorage` and
`FeatureDatabase::scope_id()` are neutral storage isolation APIs: a local Host's
persisted random scope does not create a Workspace record or imply Backend
membership. Existing placement `<root>/<scope>/features/<feature>.sqlite` and
snapshot `workspace=` keys remain unchanged for physical compatibility.
`WorkspaceFeatureStorage`, `workspace()`, `workspace_id()`, and
`for_server_database()` are Backend compatibility aliases/helpers. They do not
introduce a Server dependency. The Host is responsible for state-root privacy,
symlink/path validation, and external execution leases before constructing this
trusted manager.
`configure_connection(&rusqlite::Connection)` is reusable by other Host stores
(such as JobStore). It sets the five-second busy timeout, foreign keys ON, WAL,
and synchronous FULL before migrations/transactions. The Feature manager uses
this same helper; WAL lock-contention retries stay within the busy timeout.
Dependencies: `rusqlite` (existing workspace backup support), `thiserror`, `uuid`;
tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "job"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
manifest.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
+336
View File
@@ -0,0 +1,336 @@
//! Transport-neutral immutable Job intent and structured-result contract.
//!
//! Hosts own execution, authority, persistence and lifecycle state. This crate
//! deliberately contains no runner, state machine, Runtime or domain grants.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum JobError {
#[error("{0}")]
InvalidInput(String),
}
pub type Result<T> = std::result::Result<T, JobError>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct JobLimits {
#[serde(default = "default_max_concurrent_jobs")]
pub max_concurrent_jobs: u16,
#[serde(default = "default_job_timeout_seconds")]
pub timeout_seconds: u32,
#[serde(default = "default_max_result_bytes")]
pub max_result_bytes: u32,
#[serde(default = "default_max_attempts")]
pub max_attempts: u8,
}
impl Default for JobLimits {
fn default() -> Self {
Self {
max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS,
timeout_seconds: DEFAULT_JOB_TIMEOUT_SECONDS,
max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
max_attempts: DEFAULT_MAX_ATTEMPTS,
}
}
}
impl JobLimits {
pub fn validate(&self) -> Result<()> {
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
{
return Err(JobError::InvalidInput(format!(
"Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
)));
}
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
return Err(JobError::InvalidInput(format!(
"Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
)));
}
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
return Err(JobError::InvalidInput(format!(
"Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
)));
}
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
return Err(JobError::InvalidInput(format!(
"Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
)));
}
Ok(())
}
}
const fn default_max_concurrent_jobs() -> u16 {
DEFAULT_MAX_CONCURRENT_JOBS
}
const fn default_job_timeout_seconds() -> u32 {
DEFAULT_JOB_TIMEOUT_SECONDS
}
const fn default_max_result_bytes() -> u32 {
DEFAULT_MAX_RESULT_BYTES
}
const fn default_max_attempts() -> u8 {
DEFAULT_MAX_ATTEMPTS
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobRequest {
/// Stable caller-selected idempotency key within the host's namespace.
pub job_id: String,
pub purpose: String,
/// Revision fenced by the structured result.
pub input_revision: String,
pub input_ref: String,
/// Bounded immutable snapshot; must not contain credentials.
pub input: Value,
pub instruction: String,
/// Manifest registry selector, never raw source or a filesystem path.
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serialization_key: Option<String>,
#[serde(default)]
pub limits: JobLimits,
}
impl JobRequest {
pub fn validate(&self) -> Result<()> {
self.validate_fields()?;
self.validate_input()?;
self.limits.validate()
}
/// Request metadata validation, separable so adapters can retain their
/// domain-validation ordering before validating input and limits.
pub fn validate_fields(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
self.profile_selector()?;
if let Some(key) = &self.serialization_key {
validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?;
}
Ok(())
}
pub fn validate_input(&self) -> Result<()> {
let input = serde_json::to_vec(&self.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
if input.len() > MAX_JOB_INPUT_BYTES {
return Err(JobError::InvalidInput(format!(
"Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
)));
}
Ok(())
}
/// Syntax only; registry existence and resolution are host authority.
pub fn profile_selector(&self) -> Result<manifest::ProfileSelector> {
profile_selector(&self.profile)
}
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
fingerprint(self)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
worker_input(self, attempt_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
pub result: Value,
}
/// Client-facing lifecycle vocabulary, not a runner or transition policy.
/// A variant's presence does not imply every host supports that operation.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JobState {
Pending,
Completed,
Failed,
Unknown,
Cancelled,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JobAttemptState {
Reserved,
Dispatching,
Dispatched,
Completed,
Failed,
Unknown,
Cancelled,
}
/// Transport-neutral read snapshot. Job status/result and the corresponding
/// attempt identity/status remain distinct; host identities, delivery and
/// resource cleanup are deliberately not part of the model outcome.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct JobOutcome {
pub job_id: String,
pub input_revision: String,
pub attempt_id: String,
pub attempt: u8,
pub state: JobState,
pub attempt_state: JobAttemptState,
pub result: Option<Value>,
pub failure_category: Option<String>,
pub failure_detail: Option<String>,
}
pub fn profile_selector(profile: &str) -> Result<manifest::ProfileSelector> {
use manifest::ProfileSelector;
validate_identifier("profile", profile, 256)?;
let selector = ProfileSelector::parse_cli(profile);
let valid = match &selector {
ProfileSelector::Default => true,
ProfileSelector::Path { .. } => false,
ProfileSelector::Named { source, name } => {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains(['/', '\\', ':'])
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& ![".toml", ".json", ".dcdl", ".nix"]
.iter()
.any(|suffix| name.ends_with(suffix))
&& (source.is_some() || name != "inherit")
}
};
if !valid {
return Err(JobError::InvalidInput(
"Job profile must be a manifest registry selector, not raw source or a path".into(),
));
}
Ok(selector)
}
/// Hash the full serialized host intent, not just its neutral projection.
/// Callers must validate the complete intent first. JSON field ordering and
/// omission rules are intentionally preserved for durable replay compatibility.
pub fn fingerprint<T: Serialize + ?Sized>(intent: &T) -> Result<String> {
let encoded = serde_json::to_vec(intent)
.map_err(|error| JobError::InvalidInput(format!("serialize Job intent: {error}")))?;
Ok(sha256(&encoded))
}
pub fn worker_input(request: &JobRequest, attempt_id: &str) -> Result<String> {
worker_input_with_label(request, attempt_id, "Job")
}
/// Adapter-specific capability label; only the host, never the model, selects it.
/// Keeps an existing host's prompt envelope byte-for-byte compatible.
pub fn worker_input_with_label(
request: &JobRequest,
attempt_id: &str,
label: &str,
) -> Result<String> {
let input = serde_json::to_string(&request.input)
.map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?;
Ok(format!(
"{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.",
request.instruction,
request.job_id,
attempt_id,
request.input_revision,
request.input_ref,
input
))
}
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
format!("{job_id}:attempt:{attempt}")
}
/// Bound the encoded JSON before hashing. Returns (digest, exact JSON).
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
let encoded = serde_json::to_string(result)
.map_err(|error| JobError::InvalidInput(format!("serialize Job result: {error}")))?;
if encoded.len() > max_bytes as usize {
return Err(JobError::InvalidInput(format!(
"Job result exceeds {max_bytes} bytes"
)));
}
Ok((sha256(encoded.as_bytes()), encoded))
}
pub fn bounded_failure_detail(detail: &str) -> String {
if detail.len() <= MAX_JOB_FAILURE_BYTES {
return detail.to_string();
}
let mut end = MAX_JOB_FAILURE_BYTES;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail[..end].to_string()
}
pub fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
validate_bounded_text(field, value, max_bytes)?;
if value.chars().any(char::is_control) {
return Err(JobError::InvalidInput(format!(
"Job {field} must not contain control characters"
)));
}
Ok(())
}
pub fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
if value.is_empty() || value.trim() != value {
return Err(JobError::InvalidInput(format!(
"Job {field} must be non-empty and trimmed"
)));
}
if value.len() > max_bytes {
return Err(JobError::InvalidInput(format!(
"Job {field} exceeds {max_bytes} bytes"
)));
}
Ok(())
}
pub fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(7 + digest.len() * 2);
output.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
}
+308
View File
@@ -0,0 +1,308 @@
use job::*;
use serde_json::json;
fn request() -> JobRequest {
JobRequest {
job_id: "check:r7".into(),
purpose: "snapshot_check".into(),
input_revision: "7".into(),
input_ref: "snapshot://7".into(),
input: json!({"title": "Check me"}),
instruction: "Check this immutable snapshot.".into(),
profile: "project:custom-check".into(),
serialization_key: None,
limits: JobLimits::default(),
}
}
#[test]
fn shared_lifecycle_enums_round_trip_all_host_states() {
for (state, name) in [
(JobState::Pending, "pending"),
(JobState::Completed, "completed"),
(JobState::Failed, "failed"),
(JobState::Unknown, "unknown"),
(JobState::Cancelled, "cancelled"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
assert_eq!(
serde_json::from_value::<JobState>(json!(name)).unwrap(),
state
);
}
for (state, name) in [
(JobAttemptState::Reserved, "reserved"),
(JobAttemptState::Dispatching, "dispatching"),
(JobAttemptState::Dispatched, "dispatched"),
(JobAttemptState::Completed, "completed"),
(JobAttemptState::Failed, "failed"),
(JobAttemptState::Unknown, "unknown"),
(JobAttemptState::Cancelled, "cancelled"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
assert_eq!(
serde_json::from_value::<JobAttemptState>(json!(name)).unwrap(),
state
);
}
assert!(serde_json::from_value::<JobState>(json!("running")).is_err());
assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err());
}
#[test]
fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
let mut outcome = JobOutcome {
job_id: "job-1".into(),
input_revision: "7".into(),
attempt_id: "job-1:attempt:2".into(),
attempt: 2,
state: JobState::Pending,
attempt_state: JobAttemptState::Dispatching,
result: None,
failure_category: None,
failure_detail: None,
};
let encoded = serde_json::to_value(&outcome).unwrap();
assert_eq!(
encoded,
json!({
"job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2",
"attempt": 2, "state": "pending", "attempt_state": "dispatching",
"result": null, "failure_category": null, "failure_detail": null
})
);
assert_eq!(
serde_json::from_value::<JobOutcome>(encoded.clone()).unwrap(),
outcome
);
let mut with_host_identity = encoded;
with_host_identity["workspace_id"] = json!("workspace-a");
assert!(serde_json::from_value::<JobOutcome>(with_host_identity).is_err());
for (state, attempt_state, result, category, detail) in [
(
JobState::Completed,
JobAttemptState::Completed,
Some(json!({"ok": true})),
None,
None,
),
(
JobState::Failed,
JobAttemptState::Failed,
None,
Some("execution"),
Some("known failure"),
),
(
JobState::Unknown,
JobAttemptState::Unknown,
None,
Some("interrupted"),
Some("requires reconciliation"),
),
(
JobState::Cancelled,
JobAttemptState::Cancelled,
None,
Some("cancelled"),
Some("caller cancelled"),
),
] {
outcome.state = state;
outcome.attempt_state = attempt_state;
outcome.result = result;
outcome.failure_category = category.map(str::to_owned);
outcome.failure_detail = detail.map(str::to_owned);
assert_eq!(
serde_json::from_str::<JobOutcome>(&serde_json::to_string(&outcome).unwrap()).unwrap(),
outcome
);
}
}
#[test]
fn neutral_request_and_result_have_strict_round_trip_contracts() {
let request = request();
let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 8);
assert!(encoded.get("serialization_key").is_none());
assert_eq!(
serde_json::from_value::<JobRequest>(encoded.clone()).unwrap(),
request
);
let mut with_grant = encoded.clone();
with_grant["grants"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_grant).is_err());
let mut with_provenance = encoded;
with_provenance["source_worker"] = json!({});
assert!(serde_json::from_value::<JobRequest>(with_provenance).is_err());
let submission = JobResultSubmission {
job_id: request.job_id,
attempt_id: "check:r7:attempt:1".into(),
input_revision: request.input_revision,
result: json!({"ok": true}),
};
let mut encoded = serde_json::to_value(&submission).unwrap();
assert_eq!(
serde_json::from_value::<JobResultSubmission>(encoded.clone()).unwrap(),
submission
);
encoded["authority"] = json!("profile");
assert!(serde_json::from_value::<JobResultSubmission>(encoded).is_err());
assert_eq!(
serde_json::from_str::<JobLimits>("{}").unwrap(),
JobLimits::default()
);
assert!(serde_json::from_str::<JobLimits>(r#"{"turns":999}"#).is_err());
}
#[test]
fn fingerprint_covers_every_immutable_field_and_preserves_replay() {
let request = request();
let digest = request.fingerprint().unwrap();
let decoded: JobRequest =
serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
assert_eq!(digest, decoded.fingerprint().unwrap());
assert_eq!(digest, fingerprint(&request).unwrap());
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.input = json!({"title": "changed"}),
|r: &mut JobRequest| r.instruction.push('x'),
|r: &mut JobRequest| r.profile = "builtin:backend-job".into(),
|r: &mut JobRequest| r.serialization_key = Some("resource:7".into()),
|r: &mut JobRequest| r.limits.max_attempts = 3,
] {
let mut changed = request.clone();
mutate(&mut changed);
assert_ne!(digest, changed.fingerprint().unwrap());
}
}
#[test]
fn registry_profiles_are_not_authority_or_a_fixed_allowlist() {
for profile in [
"default",
"custom",
"project:custom",
"user:custom",
"builtin:future-job",
] {
profile_selector(profile).unwrap();
}
for profile in [
"",
" inherit",
"inherit",
"path:custom",
"./a.toml",
"/tmp/a",
"project:../a",
"project:a.toml",
"builtin:",
"unknown:a",
"project:a:b",
"C:\\custom",
"{\"worker\":{}}",
] {
assert!(profile_selector(profile).is_err(), "accepted {profile}");
}
}
#[test]
fn validation_enforces_utf8_bytes_and_metadata_bounds() {
let mut r = request();
r.job_id = "x".repeat(256);
r.input_revision = "x".repeat(256);
r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES);
r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES);
r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES);
r.serialization_key = Some("x".repeat(MAX_JOB_REFERENCE_BYTES));
r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 2));
r.validate().unwrap();
for mutate in [
|r: &mut JobRequest| r.job_id.push('x'),
|r: &mut JobRequest| r.input_revision.push('x'),
|r: &mut JobRequest| r.purpose.push('x'),
|r: &mut JobRequest| r.input_ref.push('x'),
|r: &mut JobRequest| r.instruction.push('x'),
|r: &mut JobRequest| r.serialization_key.as_mut().unwrap().push('x'),
|r: &mut JobRequest| r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 1)),
] {
let mut changed = r.clone();
mutate(&mut changed);
assert!(changed.validate().is_err());
assert!(changed.fingerprint().is_err());
}
assert!(validate_identifier("id", "a\nb", 256).is_err());
assert!(validate_bounded_text("text", " untrimmed", 256).is_err());
assert!(validate_bounded_text("text", "", 256).is_err());
assert!(validate_bounded_text("text", "界", 2).is_err());
validate_bounded_text("text", "a\nb", 256).unwrap();
}
#[test]
fn all_limit_dimensions_reject_zero_and_above_absolute_bounds() {
let maximum = JobLimits {
max_concurrent_jobs: ABSOLUTE_MAX_CONCURRENT_JOBS,
timeout_seconds: MAX_JOB_TIMEOUT_SECONDS,
max_result_bytes: ABSOLUTE_MAX_RESULT_BYTES,
max_attempts: ABSOLUTE_MAX_ATTEMPTS,
};
maximum.validate().unwrap();
for mutate in [
|l: &mut JobLimits| l.max_concurrent_jobs = 0,
|l: &mut JobLimits| l.max_concurrent_jobs += 1,
|l: &mut JobLimits| l.timeout_seconds = 0,
|l: &mut JobLimits| l.timeout_seconds += 1,
|l: &mut JobLimits| l.max_result_bytes = 0,
|l: &mut JobLimits| l.max_result_bytes += 1,
|l: &mut JobLimits| l.max_attempts = 0,
|l: &mut JobLimits| l.max_attempts += 1,
] {
let mut changed = maximum.clone();
mutate(&mut changed);
assert!(changed.validate().is_err());
}
}
#[test]
fn result_digest_and_failure_detail_bound_exact_utf8_encoding() {
let result = json!({"ok": true});
let (digest, encoded) = result_digest(&result, 11).unwrap();
assert_eq!(encoded, r#"{"ok":true}"#);
assert_eq!(digest, sha256(encoded.as_bytes()));
assert!(result_digest(&result, 10).is_err());
let unicode = json!("界");
assert!(result_digest(&unicode, 4).is_err());
result_digest(&unicode, 5).unwrap();
let detail = "界".repeat(MAX_JOB_FAILURE_BYTES);
let bounded = bounded_failure_detail(&detail);
assert_eq!(bounded.len(), MAX_JOB_FAILURE_BYTES - 1);
assert!(detail.starts_with(&bounded));
assert_eq!(bounded_failure_detail("known failure"), "known failure");
}
#[test]
fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() {
let request = request();
let attempt = attempt_id(&request.job_id, 2);
assert_eq!(attempt, "check:r7:attempt:2");
let input = request.worker_input(&attempt).unwrap();
assert!(input.starts_with(&request.instruction));
for field in [
"Job envelope (immutable):",
"job_id: check:r7",
"attempt_id: check:r7:attempt:2",
"input_revision: 7",
"input_ref: snapshot://7",
r#"input_json: {"title":"Check me"}"#,
"structured Job result capability",
"Final prose and Worker Idle/Stopped state are not result authority.",
] {
assert!(input.contains(field), "missing {field}");
}
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
builtin-source.workspace = true
arc-swap = "1"
agen = { workspace = true }
decodal.workspace = true
+1 -177
View File
@@ -2,186 +2,10 @@ use std::collections::BTreeMap;
use decodal::{Data, Engine, ImportLoader, LoadedImport};
use serde_json::{Map, Number, Value};
use sha2::{Digest, Sha256};
use crate::profile::ProfileError;
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileImport {
pub specifier: &'static str,
pub resolved_path: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileResource {
pub selector: Option<&'static str>,
pub path: &'static str,
pub source: &'static str,
pub description: &'static str,
pub imports: &'static [BuiltinProfileImport],
}
const BASE_PATH: &str = "profiles/base.dcdl";
const DEFAULT_PATH: &str = "profiles/default.dcdl";
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./base.dcdl",
resolved_path: BASE_PATH,
}];
const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./default.dcdl",
resolved_path: DEFAULT_PATH,
}];
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
BuiltinProfileResource {
selector: None,
path: BASE_PATH,
source: include_str!("../../../resources/profiles/base.dcdl"),
description: "Shared built-in Profile defaults.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some(BUILTIN_DEFAULT_PROFILE),
path: DEFAULT_PATH,
source: include_str!("../../../resources/profiles/default.dcdl"),
description: "Default Yoi coding profile.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some(BUILTIN_STANDALONE_PROFILE),
path: "profiles/standalone.dcdl",
source: include_str!("../../../resources/profiles/standalone.dcdl"),
description: "Standalone Yoi coding profile.",
imports: DEFAULT_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:coder"),
path: "profiles/coder.dcdl",
source: include_str!("../../../resources/profiles/coder.dcdl"),
description: "Ticket implementation with direct Reviewer SubWorkers.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
source: include_str!("../../../resources/profiles/companion.dcdl"),
description: "General assistance with Workspace tools.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:intake"),
path: "profiles/intake.dcdl",
source: include_str!("../../../resources/profiles/intake.dcdl"),
description: "Read-only intake and planning.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:reviewer"),
path: "profiles/reviewer.dcdl",
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
description: "Independent review of a published Merge Request source.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:orchestrator"),
path: "profiles/orchestrator.dcdl",
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
description: "Workspace orchestration and Worker control.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:backend-job"),
path: "profiles/backend-job.dcdl",
source: include_str!("../../../resources/profiles/backend-job.dcdl"),
description: "Backend-owned bounded Job Worker.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:memory-consolidation"),
path: "profiles/memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
description: "Internal Memory consolidation service.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:subjektiv-memory-consolidation"),
path: "profiles/subjektiv-memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"),
description: "Delegated subject Memory consolidation service.",
imports: BASE_IMPORT,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltinProfileCatalogSnapshot {
pub id: &'static str,
pub sources: BTreeMap<String, String>,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
}
impl BuiltinProfileCatalogSnapshot {
pub fn digest(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.id.as_bytes());
for (path, source) in &self.sources {
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
hasher.update((source.len() as u64).to_le_bytes());
hasher.update(source.as_bytes());
}
for (selector, path) in &self.entrypoints {
hasher.update((selector.len() as u64).to_le_bytes());
hasher.update(selector.as_bytes());
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
}
for (request, resolved_path) in &self.imports {
hasher.update((request.len() as u64).to_le_bytes());
hasher.update(request.as_bytes());
hasher.update((resolved_path.len() as u64).to_le_bytes());
hasher.update(resolved_path.as_bytes());
}
format!("sha256:{:x}", hasher.finalize())
}
}
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
let mut sources = BTreeMap::new();
let mut entrypoints = BTreeMap::new();
let mut imports = BTreeMap::new();
for resource in BUILTIN_PROFILE_RESOURCES {
sources.insert(resource.path.to_owned(), resource.source.to_owned());
for import in resource.imports {
imports.insert(
format!("{}\0{}", resource.path, import.specifier),
import.resolved_path.to_owned(),
);
}
if let Some(selector) = resource.selector {
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
}
}
BuiltinProfileCatalogSnapshot {
id: BUILTIN_PROFILE_CATALOG_ID,
sources,
entrypoints,
imports,
}
}
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
BUILTIN_PROFILE_RESOURCES
.iter()
.filter(|resource| resource.selector.is_some())
}
pub use builtin_source::*;
pub(crate) fn resolve_builtin_profile_artifact(
selector: &str,
+24 -2
View File
@@ -189,7 +189,9 @@ pub fn validate_profile_execution_target(
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.subjektiv.profile.enabled {
// Subject policy is Host-neutral. Only an already bound Backend snapshot
// requires Workspace authority; a local Host supplies its own live capability.
if feature.subjektiv.workspace_settings.is_some() {
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
}
if feature.merge_request.show
@@ -1257,6 +1259,23 @@ mod tests {
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "role-worker");
if matches!(
entry.name.as_str(),
"job" | "standalone-subjektiv-consolidation"
) {
assert!(resolved.manifest.compaction.is_none());
assert!(resolved.manifest.scope.allow.is_empty());
assert!(resolved.manifest.delegation_scope.allow.is_empty());
assert_eq!(
resolved.manifest.engine.instruction,
if entry.name == "job" {
"internal.job_system"
} else {
"internal.subjektiv_memory_consolidation_system"
}
);
continue;
}
}
}
@@ -1558,7 +1577,10 @@ mod tests {
panic!("unexpected error: {error}");
};
assert_eq!(target, ProfileExecutionTarget::Standalone);
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
assert!(
!requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv),
"subject policy alone is no longer Backend authority"
);
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
+26
View File
@@ -50,6 +50,9 @@ pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
/// Stamped only by the operator-owned standalone in-process transport.
/// This is not a Backend Account or authority accepted from wire fields.
LocalOperator,
Account {
account_id: String,
},
@@ -86,6 +89,7 @@ impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
Self::UntrustedWire => "untrusted-wire".into(),
Self::LocalOperator => "local-operator".into(),
Self::Account { account_id } => format!("account:{account_id}"),
Self::Worker {
runtime_id,
@@ -2175,6 +2179,28 @@ mod tests {
);
}
#[test]
fn local_operator_source_cannot_be_granted_by_serialized_input() {
let method = Method::SubmitTracked {
input: vec![Segment::text("forged local operator")],
submission_request_id: "local-forged".into(),
source: AuthenticatedInputSource::LocalOperator,
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(matches!(
decoded,
Method::SubmitTracked {
source: AuthenticatedInputSource::UntrustedWire,
..
}
));
assert_ne!(
AuthenticatedInputSource::LocalOperator.namespace(),
AuthenticatedInputSource::UntrustedWire.namespace()
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
+4
View File
@@ -4496,6 +4496,10 @@ pub struct ConfigSchemaContribution {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub projection_validator: Option<ConfigProjectionValidator>,
/// Completion-only shape for partial values; never materialized as defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub authoring_source: Option<String>,
pub source_digest: String,
}
+1 -1
View File
@@ -211,7 +211,7 @@ impl FsStore {
}
}
fn parse_jsonl(
pub(crate) fn parse_jsonl(
content: &[u8],
session_id: SessionId,
segment_id: SegmentId,
+2
View File
@@ -40,6 +40,8 @@ pub struct LoggedWorkerSubject {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedSessionHistoryOrigin {
/// Human input verified by the standalone Host, without a Backend Account.
LocalHumanInput,
HumanInput {
account_id: String,
},
+167 -3
View File
@@ -208,6 +208,172 @@ pub fn read_session_public_index(
let segment_ids = store
.list_segments_read_only(session_id)
.map_err(map_store_error)?;
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)
})
}
/// Strict committed public projection for the standalone FsStore layout.
/// Uses the same lineage, commit filtering and entry projection as retained
/// WorkerSessionStore observation. No writes, migrations, trace/artifact reads,
/// access-time updates or directory creation occur here.
pub fn read_fs_session_public_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
read_fs_session_evidence_index(sessions_root, session_id, limits).map(|value| value.index)
}
/// Producer-compatible numeric hints for stable public evidence references.
/// Both committed annotated-history and filtered public-snapshot capture are
/// valid producers. No hidden history content is exposed by this metadata.
pub struct FsSessionEvidenceIndex {
pub index: SessionPublicIndex,
pub ranges: HashMap<(String, String), Vec<[u64; 2]>>,
pub has_open_run: bool,
}
pub fn read_fs_session_evidence_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<FsSessionEvidenceIndex, SessionPublicIndexReadError> {
if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let session_dir = sessions_root.join(session_id.to_string());
// IDs are typed and every storage component is observationally validated;
// a known Session ID never authorizes following arbitrary local symlinks.
let mut prefix = std::path::PathBuf::new();
for part in session_dir.components() {
prefix.push(part);
let metadata =
std::fs::symlink_metadata(&prefix).map_err(|_| SessionPublicIndexReadError::Missing)?;
if metadata.file_type().is_symlink() {
return Err(SessionPublicIndexReadError::Storage);
}
}
let mut segment_ids = Vec::new();
for entry in
std::fs::read_dir(&session_dir).map_err(|_| SessionPublicIndexReadError::Missing)?
{
let entry = entry.map_err(|_| SessionPublicIndexReadError::Storage)?;
let path = entry.path();
if path.extension().and_then(|v| v.to_str()) != Some("jsonl") {
continue;
}
let Some(id) = path
.file_stem()
.and_then(|v| v.to_str())
.and_then(|v| v.parse::<SegmentId>().ok())
else {
continue;
};
if !entry
.file_type()
.map_err(|_| SessionPublicIndexReadError::Storage)?
.is_file()
{
return Err(SessionPublicIndexReadError::Storage);
}
segment_ids.push(id);
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
}
segment_ids.sort();
if segment_ids.is_empty() {
return Err(SessionPublicIndexReadError::Missing);
}
let mut ranges = HashMap::<(String, String), Vec<[u64; 2]>>::new();
let mut has_open_run = false;
let index =
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
let path = session_dir.join(format!("{segment_id}.jsonl"));
let bytes = crate::read_without_atime_bounded(&path, remaining)
.map_err(|_| SessionPublicIndexReadError::Storage)?;
if bytes.len() as u64 > remaining {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let entries = crate::FsStore::parse_jsonl(&bytes, session_id, segment_id)
.map_err(map_store_error)?;
let mut open = false;
for entry in &entries {
match entry {
LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => open = true,
LogEntry::RunCompleted { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }
| LogEntry::PausedTurnAbandoned { .. } => open = false,
_ => {}
}
}
has_open_run |= open;
let committed = committed_conversation_records(&entries);
let state = crate::segment_log::collect_state(&committed);
for (position, entry) in state.annotated_history.iter().enumerate() {
ranges
.entry((
segment_id.to_string(),
format!("E{}", entry.metadata.entry_id.0),
))
.or_default()
.push([position as u64, position as u64]);
}
let snapshot = crate::public_snapshot::project_session_snapshot_for_segment(
session_id,
Some(segment_id),
&committed,
);
let mut position = 0_u64;
for entry in snapshot.entries {
if matches!(
entry.data,
SessionSnapshotEntryData::UserInput { .. }
| SessionSnapshotEntryData::Message { .. }
| SessionSnapshotEntryData::ToolCall { .. }
| SessionSnapshotEntryData::ToolResult { .. }
) {
ranges
.entry((segment_id.to_string(), format!("E{}", entry.entry_id)))
.or_default()
.push([position, position]);
position += 1;
}
}
Ok((entries, bytes.len() as u64))
})?;
// Intersect with the strict public projection, never turn knowledge of a
// hidden/reasoning history identity into a public evidence reference.
let public = index
.segments
.iter()
.flat_map(|seg| {
seg.entries
.iter()
.map(|entry| (seg.segment_id.clone(), entry.entry_ref.clone()))
})
.collect::<HashSet<_>>();
ranges.retain(|key, _| public.contains(key));
Ok(FsSessionEvidenceIndex {
index,
ranges,
has_open_run,
})
}
fn project_public_index_records(
session_id: SessionId,
segment_ids: Vec<SegmentId>,
limits: SessionPublicIndexLimits,
mut read_segment: impl FnMut(
SegmentId,
u64,
) -> Result<(Vec<LogEntry>, u64), SessionPublicIndexReadError>,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
@@ -224,9 +390,7 @@ pub fn read_session_public_index(
.max_bytes
.checked_sub(scanned_bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
let (entries, bytes) = store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)?;
let (entries, bytes) = read_segment(segment_id, remaining)?;
scanned_bytes = scanned_bytes
.checked_add(bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
+2 -1
View File
@@ -1654,7 +1654,8 @@ fn legacy_entry_id(
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
match origin {
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::HumanInput { .. }
| LoggedSessionHistoryOrigin::LocalHumanInput => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction
+8
View File
@@ -9,7 +9,15 @@ license.workspace = true
agen.workspace = true
client.workspace = true
fs4.workspace = true
feature-storage.workspace = true
subjektiv.workspace = true
memory.workspace = true
server-api.workspace = true
libc.workspace = true
async-trait.workspace = true
manifest.workspace = true
job.workspace = true
rusqlite.workspace = true
protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
+253 -1
View File
@@ -43,12 +43,16 @@ pub struct StandaloneHost {
worker_store: FsWorkerStore,
record: StandaloneWorkerRecord,
lease: Option<StandaloneWorkerLease>,
jobs: crate::jobs::StandaloneJobs,
subject: Option<crate::subjektiv::SubjectConnection>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StandaloneStartupError {
#[error("the standalone state store could not be opened or validated")]
StateStore,
#[error("local Subject connection rejected: {0}")]
Subject(String),
#[error("the standalone Worker is already active")]
WorkerActive,
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
@@ -77,6 +81,8 @@ pub enum StandaloneStartupError {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneShutdownError {
#[error("standalone Job execution cleanup or terminal persistence could not be confirmed")]
JobCleanup,
#[error("the standalone Worker did not stop before the shutdown deadline")]
DeadlineExceeded,
#[error("the standalone Worker shutdown confirmation was lost")]
@@ -104,13 +110,66 @@ impl StandaloneHost {
launch: ResolvedStandaloneLaunch,
model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> {
if launch.subject_id.is_some()
&& (!launch.profile.manifest.feature.subjektiv.profile.enabled
|| launch
.profile
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| launch
.profile
.manifest
.feature
.subjektiv
.workspace_settings
.is_some())
{
return Err(StandaloneStartupError::Subject(
"incompatible local Subject policy".into(),
));
}
let store =
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
let allocation = store
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
.map_err(classify_store_startup_error)?;
let worker_id = allocation.worker_id();
let mut subject = match launch
.subject_id
.as_deref()
.map(|id| {
crate::subjektiv::StandaloneSubjects::open_existing(&launch.state_dir)?
.connect(id, worker_id)
})
.transpose()
{
Ok(subject) => subject,
Err(error) => {
let _ = store.abandon_allocation(allocation);
return Err(StandaloneStartupError::Subject(error.to_string()));
}
};
let jobs_path = subject
.as_ref()
.map(|s| s.jobs_path.clone())
.unwrap_or_else(|| store.jobs_path(worker_id));
let jobs = match crate::jobs::StandaloneJobs::open(&jobs_path, launch.cwd.clone()) {
Ok(jobs) => jobs,
Err(_) => {
let _ = store.abandon_allocation(allocation);
return Err(StandaloneStartupError::StateStore);
}
};
if let Some(client) = &model_client {
jobs.bind_model_client(client.clone_boxed());
}
if let Some(subject) = &subject {
subject.host.bind_jobs(jobs.clone())?;
}
// WorkerId is the stable identity. The current Worker store remains
// name-keyed, so keep its derived storage key separate from the
// user-facing profile name.
@@ -143,9 +202,15 @@ impl StandaloneHost {
},
WorkerControllerTransport::InProcess,
);
if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
}
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
if let Some(subject) = &mut subject {
subject.arm();
}
let started = match bootstrap.start().await {
Ok(started) => started,
Err(error) => {
@@ -161,12 +226,13 @@ impl StandaloneHost {
return Err(error);
}
};
let record = match store.commit_created(
let record = match store.commit_created_connected(
&allocation,
manifest,
storage_key,
active.session_id,
active.segment_id,
subject.as_ref().map(|s| s.binding.clone()),
) {
Ok(record) => record,
Err(_) => {
@@ -181,6 +247,8 @@ impl StandaloneHost {
worker_store,
record,
allocation.into_lease(),
jobs,
subject,
))
}
@@ -216,6 +284,50 @@ impl StandaloneHost {
let lease = store
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
.map_err(classify_store_startup_error)?;
let mut subject = record
.subject
.as_ref()
.map(|binding| {
if !record.manifest.feature.subjektiv.profile.enabled
|| record
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| record
.manifest
.feature
.subjektiv
.workspace_settings
.is_some()
{
return Err(crate::subjektiv::SubjectError::InvalidScope(
"persisted Subject policy changed".into(),
));
}
let catalog = crate::subjektiv::StandaloneSubjects::open_existing(store.root())?;
if catalog.scope_id() != binding.scope_id {
return Err(crate::subjektiv::SubjectError::InvalidScope(
"persisted local storage scope changed".into(),
));
}
catalog.connect(&binding.subject_id, worker_id)
})
.transpose()
.map_err(|e| StandaloneStartupError::Subject(e.to_string()))?;
let jobs_path = subject
.as_ref()
.map(|s| s.jobs_path.clone())
.unwrap_or_else(|| store.jobs_path(worker_id));
let jobs = crate::jobs::StandaloneJobs::open(&jobs_path, record.cwd.canonical_path.clone())
.map_err(|_| StandaloneStartupError::StateStore)?;
if let Some(client) = &model_client {
jobs.bind_model_client(client.clone_boxed());
}
if let Some(subject) = &subject {
subject.host.bind_jobs(jobs.clone())?;
}
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
let storage_key = record.storage_key.clone();
let mut manifest = record.manifest.clone();
@@ -240,6 +352,9 @@ impl StandaloneHost {
},
WorkerControllerTransport::InProcess,
);
if let Some(subject) = &subject {
bootstrap = bootstrap.with_subjektiv_host(subject.host.clone());
}
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
@@ -247,6 +362,9 @@ impl StandaloneHost {
.prepare_restored(&storage_key)
.await
.map_err(classify_startup_error)?;
if let Some(subject) = &mut subject {
subject.arm();
}
let started = prepared.start().await.map_err(classify_startup_error)?;
let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active,
@@ -270,6 +388,8 @@ impl StandaloneHost {
worker_store,
record,
lease,
jobs,
subject,
))
}
@@ -279,7 +399,12 @@ impl StandaloneHost {
worker_store: FsWorkerStore,
record: StandaloneWorkerRecord,
lease: StandaloneWorkerLease,
jobs: crate::jobs::StandaloneJobs,
subject: Option<crate::subjektiv::SubjectConnection>,
) -> Self {
if let Some(connection) = &subject {
connection.host.recover_jobs();
}
Self {
handle: started.handle,
shutdown: Some(started.shutdown),
@@ -288,9 +413,17 @@ impl StandaloneHost {
worker_store,
record,
lease: Some(lease),
jobs,
subject,
}
}
/// Explicit Host execution capability for local Features, separate from the
/// interactive Worker protocol and its model-created SubWorkers.
pub fn jobs(&self) -> crate::jobs::StandaloneJobs {
self.jobs.clone()
}
#[must_use]
pub fn worker_id(&self) -> WorkerId {
self.record.worker_id
@@ -318,6 +451,7 @@ impl StandaloneHost {
}
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let jobs_result = self.jobs.shutdown().await;
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
let _ = self.handle.send(Method::Shutdown { command }).await;
let Some(shutdown) = self.shutdown.take() else {
@@ -335,6 +469,10 @@ impl StandaloneHost {
return Err(StandaloneShutdownError::DeadlineExceeded);
}
}
if jobs_result.is_err() {
self.retain_lease();
return Err(StandaloneShutdownError::JobCleanup);
}
let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
Ok(active) => active,
Err(_) => {
@@ -355,6 +493,12 @@ impl StandaloneHost {
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
if let Some(subject) = &mut self.subject {
if subject.close().is_err() {
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
}
if let Some(lease) = self.lease.take() {
lease
.release()
@@ -364,12 +508,70 @@ impl StandaloneHost {
}
fn retain_lease(&mut self) {
if let Some(subject) = &mut self.subject {
subject.retain();
}
if let Some(lease) = self.lease.take() {
lease.retain();
}
}
}
impl Drop for StandaloneHost {
fn drop(&mut self) {
self.jobs.close();
// A dropped Host cannot synchronously confirm either controller or Job
// cleanup. Keep its lease until explicit shutdown or process recovery,
// preventing another Host from reopening a still-live execution store.
self.retain_lease();
}
}
// The in-process channel is owned by the local operator's Host, not an
// arbitrary socket or Backend account. decode_method discards wire provenance;
// only this boundary can stamp accepted local human input.
fn authorize_local_input(method: protocol::Method) -> protocol::Method {
use protocol::{AuthenticatedInputSource, Method};
match method {
Method::Submit {
submission_request_id,
input,
}
| Method::SubmitTracked {
submission_request_id,
input,
..
} => Method::SubmitTracked {
submission_request_id,
input,
source: AuthenticatedInputSource::LocalOperator,
},
Method::SubmitIfIdle {
submission_request_id,
input,
..
} => Method::SubmitIfIdle {
submission_request_id,
input,
source: AuthenticatedInputSource::LocalOperator,
},
Method::Notify {
notification_request_id,
message,
}
| Method::NotifyTracked {
notification_request_id,
message,
..
} => Method::NotifyTracked {
notification_request_id,
message,
source: AuthenticatedInputSource::LocalOperator,
},
method => method,
}
}
async fn run_protocol_session(
handle: worker::WorkerHandle,
streams: WorkerProtocolSessionStreams,
@@ -395,6 +597,7 @@ async fn run_protocol_session(
let Ok(method) = decode_method(&message) else {
return;
};
let method = authorize_local_input(method);
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
&& !send_protocol_event(&peer, event).await
{
@@ -535,6 +738,9 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
StandaloneStartupError::ModelProvider
}
WorkerBootstrapError::Worker(WorkerError::SubjektivSessionAttribution {
message, ..
}) => StandaloneStartupError::Subject(message),
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
WorkerBootstrapError::Controller { source, .. }
if source.kind() == std::io::ErrorKind::Other =>
@@ -544,3 +750,49 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
}
}
#[cfg(test)]
mod local_input_tests {
use super::*;
use protocol::{AuthenticatedInputSource, Method};
#[test]
fn local_transport_stamps_operator_without_backend_account() {
let source = AuthenticatedInputSource::Account {
account_id: "forged-account".into(),
};
for method in [
Method::submit_text("plain", "hello"),
Method::SubmitTracked {
submission_request_id: "tracked".into(),
input: vec![],
source: source.clone(),
},
Method::SubmitIfIdle {
submission_request_id: "idle".into(),
input: vec![],
source: source.clone(),
},
Method::Notify {
notification_request_id: "plain-notify".into(),
message: "hello".into(),
},
Method::NotifyTracked {
notification_request_id: "tracked-notify".into(),
message: "hello".into(),
source,
},
] {
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
match authorize_local_input(decoded) {
Method::SubmitTracked { source, .. }
| Method::SubmitIfIdle { source, .. }
| Method::NotifyTracked { source, .. } => {
assert_eq!(source, AuthenticatedInputSource::LocalOperator)
}
other => panic!("unexpected local input {other:?}"),
}
}
}
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
//! Host-provided local Jobs. No Runtime registry, Workspace client or dialog-session state.
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::llm_client::LlmClient;
use job::{JobRequest, JobResultSubmission};
use tokio::sync::watch;
use crate::job_store::JobStore;
pub use crate::job_store::{JobAttempt, JobAttemptState, JobSnapshot, JobState, JobStoreError};
#[derive(Debug, thiserror::Error)]
pub enum JobServiceError {
#[error(transparent)]
Store(#[from] JobStoreError),
#[error("standalone Job execution requirements are unavailable: {0}")]
Configuration(String),
#[error("the standalone Job Host is shutting down")]
Closed,
#[error("standalone Job execution capacity is exhausted")]
Capacity,
#[error("standalone Job cleanup failed: {0}")]
Cleanup(String),
}
pub(crate) trait JobAttemptFence: Send + Sync {
fn ensure_live(&self) -> Result<(), String>;
}
pub(crate) trait JobDomainProvider: Send + Sync {
fn grant(
&self,
snapshot: &JobSnapshot,
fence: Arc<dyn JobAttemptFence>,
) -> Result<worker::job::JobFeatureGrant, String>;
}
struct Execution {
cancel: watch::Sender<bool>,
complete: watch::Receiver<Option<Result<(), String>>>,
attempt_id: String,
resource_key: Option<String>,
max_concurrent: u16,
task: tokio::task::JoinHandle<Result<(), JobServiceError>>,
}
struct Executions {
closed: bool,
jobs: HashMap<String, Execution>,
}
/// Cloneable Feature-facing access to a Host-owned service. Cloning this handle
/// neither extends the Host's authority nor permits opening/recovering its store.
#[derive(Clone)]
pub struct StandaloneJobs {
store: Arc<Mutex<Option<JobStore>>>,
executions: Arc<Mutex<Executions>>,
cwd: PathBuf,
capacity: u16,
domain: Arc<Mutex<Option<Arc<dyn JobDomainProvider>>>>,
model_client: Arc<Mutex<Option<Box<dyn LlmClient>>>>,
}
impl StandaloneJobs {
pub(crate) fn open(path: &Path, cwd: PathBuf) -> Result<Self, JobServiceError> {
Ok(Self {
store: Arc::new(Mutex::new(Some(JobStore::open(path)?))),
executions: Arc::new(Mutex::new(Executions {
closed: false,
jobs: HashMap::new(),
})),
cwd,
capacity: job::DEFAULT_MAX_CONCURRENT_JOBS,
domain: Arc::default(),
model_client: Arc::default(),
})
}
pub(crate) fn bind_model_client(&self, client: Box<dyn LlmClient>) {
*self
.model_client
.lock()
.expect("Job model transport poisoned") = Some(client);
}
pub(crate) fn bind_domain(
&self,
provider: Arc<dyn JobDomainProvider>,
) -> Result<(), JobServiceError> {
let mut domain = self.domain.lock().expect("Job domain poisoned");
if domain.is_some() {
return Err(JobServiceError::Configuration(
"Job domain already bound".into(),
));
}
*domain = Some(provider);
Ok(())
}
pub(crate) fn request_granted(
&self,
request: JobRequest,
grant: serde_json::Value,
) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
self.with_store(|store| store.reserve_granted(request, Some(grant)))
}
fn with_store<T>(
&self,
operation: impl FnOnce(&mut JobStore) -> Result<T, JobStoreError>,
) -> Result<T, JobServiceError> {
let mut slot = self.store.lock().expect("Job store poisoned");
Ok(operation(slot.as_mut().ok_or(JobServiceError::Closed)?)?)
}
/// Persist intent without starting a model. Exact resend returns the same
/// current attempt, including after acknowledgement or cancellation.
pub fn request(&self, request: JobRequest) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
Ok(self.with_store(|store| store.reserve(request))?)
}
pub fn get(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
Ok(self.with_store(|store| store.get(job_id))?)
}
/// Pending attempts survive shutdown. The consumer chooses when to resume
/// them; opening a Host never implicitly replays unknown side effects.
pub fn pending(&self) -> Result<Vec<JobSnapshot>, JobServiceError> {
Ok(self.with_store(|store| store.pending())?)
}
pub fn unacknowledged_results(&self) -> Result<Vec<JobSnapshot>, JobServiceError> {
self.with_store(|store| store.unacknowledged())
}
/// Explicit new attempt, bounded by immutable intent limits. Unknown
/// outcomes cannot be retried without domain reconciliation/new intent.
pub fn retry(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
if executions
.jobs
.get(job_id)
.is_some_and(|run| !run.task.is_finished())
{
return Err(JobServiceError::Capacity);
}
Ok(self.with_store(|store| store.retry(job_id))?)
}
/// Successful model processing and consumer postprocessing have independent
/// lifetimes. A failed delivery simply leaves this acknowledgement pending.
pub fn acknowledge(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
Ok(self.with_store(|store| store.ack(job_id))?)
}
pub fn cancel(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
let executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
let snapshot = self.with_store(|store| store.cancel(job_id))?;
if let Some(execution) = executions.jobs.get(job_id) {
execution.cancel.send_replace(true);
}
Ok(snapshot)
}
pub async fn start(&self, job_id: &str) -> Result<(), JobServiceError> {
self.start_with_optional_model_client(job_id, None).await
}
/// Injectable model transport for embedders and scripted execution tests.
/// The requested registry Profile still resolves/validates normally; the
/// transport grants no filesystem, Feature or result-submission authority.
pub async fn start_with_model_client<C>(
&self,
job_id: &str,
client: C,
) -> Result<(), JobServiceError>
where
C: LlmClient + 'static,
{
self.start_with_optional_model_client(job_id, Some(Box::new(client)))
.await
}
async fn start_with_optional_model_client(
&self,
job_id: &str,
client: Option<Box<dyn LlmClient>>,
) -> Result<(), JobServiceError> {
let client = client.or_else(|| {
self.model_client
.lock()
.expect("Job model transport poisoned")
.as_ref()
.map(|c| c.clone_boxed())
});
let snapshot = self.get(job_id)?;
let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds));
let deadline = tokio::time::Instant::now() + timeout;
let fence: Arc<dyn JobAttemptFence> = Arc::new(BoundAttemptFence {
store: self.store.clone(),
job_id: job_id.into(),
attempt_id: snapshot.attempt.attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
deadline,
});
let grant = match (
&snapshot.domain_grant,
self.domain.lock().expect("Job domain poisoned").as_ref(),
) {
(Some(_), Some(provider)) => provider
.grant(&snapshot, fence)
.map_err(JobServiceError::Configuration)?,
(Some(_), None) => {
return Err(JobServiceError::Configuration(
"persisted Job requires an unavailable explicit domain capability".into(),
));
}
(None, _) => worker::job::JobFeatureGrant::default(),
};
let prepared =
worker::job::prepare_job_with_grant(&snapshot.request, &self.cwd, client, grant)
.map_err(|error| JobServiceError::Configuration(error.to_string()))?;
let mut executions = self.executions.lock().expect("Job executions poisoned");
if executions.closed {
return Err(JobServiceError::Closed);
}
executions.jobs.retain(|_, run| {
!(run.task.is_finished() && matches!(run.complete.borrow().as_ref(), Some(Ok(()))))
});
// Count live resources, not only result states: acceptance can precede
// the Internal Worker's cleanup and must not free a concurrency slot.
let live = || {
executions
.jobs
.values()
.filter(|run| !run.task.is_finished())
};
let count = live().count();
let active_limit = live()
.map(|run| run.max_concurrent)
.min()
.unwrap_or(self.capacity);
if count >= usize::from(self.capacity.min(active_limit))
|| count >= usize::from(snapshot.request.limits.max_concurrent_jobs)
|| executions
.jobs
.get(job_id)
.is_some_and(|run| !run.task.is_finished())
{
return Err(JobServiceError::Capacity);
}
let resource_key = snapshot.request.serialization_key.clone();
if let Some(key) = &resource_key {
if executions
.jobs
.values()
.any(|run| !run.task.is_finished() && run.resource_key.as_ref() == Some(key))
{
return Err(JobServiceError::Capacity);
}
}
// Retain errors from finished tasks until a caller observes them rather
// than silently swallowing store/cleanup failures.
if let Some(previous) = executions.jobs.get(job_id) {
if !previous.task.is_finished() {
return Err(JobServiceError::Capacity);
}
}
let snapshot = self.with_store(|store| {
store.dispatch(job_id, &snapshot.attempt.attempt_id, self.capacity)
})?;
let store = self.store.clone();
let id = job_id.to_string();
let attempt_id = snapshot.attempt.attempt_id.clone();
let sink = Arc::new(BoundResultSink {
store: store.clone(),
job_id: id.clone(),
attempt_id: attempt_id.clone(),
input_revision: snapshot.request.input_revision.clone(),
deadline,
});
let (cancel, mut cancellation) = watch::channel(false);
let (complete_tx, complete) = watch::channel(None);
let execution_attempt = attempt_id.clone();
let task = tokio::spawn(async move {
let outcome: Result<(), JobServiceError> = async {
let (sender_tx, mut sender_rx) = tokio::sync::oneshot::channel();
let run = prepared.run(attempt_id.clone(), sink, move |sender| { let _ = sender_tx.send(sender); });
tokio::pin!(run);
let reason = tokio::select! {
result = &mut run => {
let (state, detail) = match result {
Ok(_) => (JobAttemptState::Failed, "Worker finished without an accepted structured result".to_string()),
Err(error @ worker::job::JobExecutionError::ResultUnconfirmed(_)) => (JobAttemptState::Unknown, error.to_string()),
Err(error) => (JobAttemptState::Failed, error.to_string()),
};
finish_if_pending(&store, &id, &attempt_id, state, &detail)?;
return Ok(());
}
_ = tokio::time::sleep(timeout) => "timeout",
_ = cancellation.wait_for(|cancelled| *cancelled) => "Host cancellation/shutdown",
};
// Fence timeout/shutdown before cancelling so a late tool call can
// never turn an interrupted unaccepted execution into success.
let fence_result = finish_if_pending(&store, &id, &attempt_id, JobAttemptState::Unknown, reason);
// Cancellation is routed through Engine; keep the future alive to
// allow normal Feature cleanup. Hard stop is a fenced unknown
// outcome, never an automatic retry. Result acceptance wins races.
if let Ok(sender) = sender_rx.try_recv() {
let _ = sender.try_send(());
} else if let Ok(Ok(sender)) = tokio::time::timeout(Duration::from_secs(1), &mut sender_rx).await {
let _ = sender.try_send(());
}
let cleanup = tokio::time::timeout(Duration::from_secs(2), &mut run).await;
fence_result?;
if cleanup.is_err() { return Err(JobServiceError::Cleanup("Internal Worker cancellation deadline exceeded; outcome fenced unknown".into())); }
Ok(())
}.await;
complete_tx.send_replace(Some(
outcome.as_ref().map(|_| ()).map_err(ToString::to_string),
));
outcome
});
executions.jobs.insert(
job_id.to_string(),
Execution {
cancel,
complete,
task,
attempt_id: execution_attempt,
resource_key,
max_concurrent: snapshot.request.limits.max_concurrent_jobs,
},
);
Ok(())
}
/// Observe execution/cleanup, not success: read the durable result separately.
pub async fn wait(&self, job_id: &str) -> Result<JobSnapshot, JobServiceError> {
let execution = self
.executions
.lock()
.expect("Job executions poisoned")
.jobs
.get(job_id)
.map(|run| (run.attempt_id.clone(), run.complete.clone()));
if let Some((attempt, mut completion)) = execution {
let result = completion
.wait_for(Option::is_some)
.await
.map_err(|error| JobServiceError::Cleanup(error.to_string()))?
.clone()
.expect("completion");
result.map_err(JobServiceError::Cleanup)?;
let task = {
let mut executions = self.executions.lock().expect("Job executions poisoned");
if executions
.jobs
.get(job_id)
.is_some_and(|run| run.attempt_id == attempt)
{
executions.jobs.remove(job_id)
} else {
None
}
};
if let Some(execution) = task {
execution
.task
.await
.map_err(|error| JobServiceError::Cleanup(error.to_string()))??;
}
}
self.get(job_id)
}
pub(crate) fn close(&self) {
let mut executions = self.executions.lock().expect("Job executions poisoned");
executions.closed = true;
for execution in executions.jobs.values() {
execution.cancel.send_replace(true);
}
}
pub(crate) async fn shutdown(&self) -> Result<(), JobServiceError> {
self.close();
let tasks = {
let mut executions = self.executions.lock().expect("Job executions poisoned");
std::mem::take(&mut executions.jobs)
};
let mut error = None;
for execution in tasks.into_values() {
match execution.task.await {
Ok(Ok(())) => {}
Ok(Err(failure)) => {
error = Some(failure);
}
Err(failure) => {
error = Some(JobServiceError::Cleanup(failure.to_string()));
}
}
}
// Close SQLite before the Host releases its lease. Cloned handles may
// outlive the Host but cannot mutate or retain the live connection.
self.store.lock().expect("Job store poisoned").take();
self.domain.lock().expect("Job domain poisoned").take();
self.model_client
.lock()
.expect("Job model transport poisoned")
.take();
error.map_or(Ok(()), Err)
}
}
fn finish_if_pending(
store: &Mutex<Option<JobStore>>,
id: &str,
attempt: &str,
state: JobAttemptState,
detail: &str,
) -> Result<(), JobServiceError> {
let mut slot = store.lock().expect("Job store poisoned");
let store = slot.as_mut().ok_or(JobServiceError::Closed)?;
let snapshot = store.get(id)?;
if snapshot.state == JobState::Pending {
store.finish(id, attempt, state, Some(detail))?;
}
Ok(())
}
struct BoundResultSink {
store: Arc<Mutex<Option<JobStore>>>,
job_id: String,
attempt_id: String,
input_revision: String,
deadline: tokio::time::Instant,
}
impl worker::job::JobResultSink for BoundResultSink {
fn submit(&self, submission: JobResultSubmission) -> Result<(), String> {
if submission.job_id != self.job_id
|| submission.attempt_id != self.attempt_id
|| submission.input_revision != self.input_revision
{
return Err("Job result capability binding mismatch".into());
}
let mut slot = self
.store
.lock()
.map_err(|_| "Job store poisoned".to_string())?;
let store = slot.as_mut().ok_or_else(|| "Job Host closed".to_string())?;
let snapshot = store.get(&self.job_id).map_err(|error| error.to_string())?;
if tokio::time::Instant::now() >= self.deadline && snapshot.state != JobState::Completed {
return Err("Job result deadline expired".into());
}
store
.accept(&submission)
.map(|_| ())
.map_err(|error| error.to_string())
}
}
struct BoundAttemptFence {
store: Arc<Mutex<Option<JobStore>>>,
job_id: String,
attempt_id: String,
input_revision: String,
deadline: tokio::time::Instant,
}
impl JobAttemptFence for BoundAttemptFence {
fn ensure_live(&self) -> Result<(), String> {
if tokio::time::Instant::now() >= self.deadline {
return Err("Job domain deadline expired".into());
}
let slot = self.store.lock().map_err(|_| "Job store poisoned")?;
let snapshot = slot
.as_ref()
.ok_or("Job Host closed")?
.get(&self.job_id)
.map_err(|e| e.to_string())?;
if snapshot.state != JobState::Pending
|| snapshot.attempt.state != JobAttemptState::Dispatched
|| snapshot.attempt.attempt_id != self.attempt_id
|| snapshot.request.input_revision != self.input_revision
{
return Err("Job domain grant is not bound to the current live attempt".into());
}
Ok(())
}
}
+471
View File
@@ -0,0 +1,471 @@
use super::*;
use agen::llm_client::{ClientError, Request, event::Event};
use async_trait::async_trait;
use futures::Stream;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::pin::Pin;
use worker::job::JobResultSink;
fn request(id: &str) -> JobRequest {
JobRequest {
job_id: id.into(),
purpose: "test".into(),
input_revision: "r1".into(),
input_ref: "fixture:input".into(),
input: serde_json::json!({"data":42}),
instruction: "Return a structured result".into(),
profile: "builtin:job".into(),
serialization_key: None,
limits: job::JobLimits::default(),
}
}
fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink {
BoundResultSink {
store: jobs.store.clone(),
job_id: id.into(),
attempt_id: attempt.into(),
input_revision: "r1".into(),
deadline: tokio::time::Instant::now() + Duration::from_secs(10),
}
}
fn result(id: &str, attempt: &str) -> JobResultSubmission {
JobResultSubmission {
job_id: id.into(),
attempt_id: attempt.into(),
input_revision: "r1".into(),
result: serde_json::json!({"valid":true}),
}
}
#[derive(Clone, Default)]
struct DomainTestClient {
scripts: Arc<Mutex<VecDeque<Vec<Event>>>>,
requests: Arc<Mutex<Vec<Request>>>,
entered: Arc<tokio::sync::Notify>,
stall: bool,
failure_release: Option<Arc<tokio::sync::Notify>>,
}
impl DomainTestClient {
fn success() -> Self {
Self {
scripts: Arc::new(Mutex::new(VecDeque::from([
vec![
Event::tool_use_start(0, "result-1", "SubmitJobResult"),
Event::tool_input_delta(0, r#"{"result":{"valid":true}}"#),
Event::tool_use_stop(0),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "done"),
Event::text_block_stop(0, None),
],
]))),
..Default::default()
}
}
}
#[async_trait]
impl LlmClient for DomainTestClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.requests.lock().unwrap().push(request);
self.entered.notify_one();
if let Some(release) = &self.failure_release {
release.notified().await;
return Err(ClientError::Config(
"known scripted provider failure".into(),
));
}
if self.stall {
return Ok(Box::pin(futures::stream::pending()));
}
let events = self
.scripts
.lock()
.unwrap()
.pop_front()
.expect("unexpected model turn");
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
type CapturedDomainAttempt = (JobSnapshot, Arc<dyn JobAttemptFence>);
#[derive(Default)]
struct RecordingDomainProvider(Mutex<Vec<CapturedDomainAttempt>>);
fn domain_grant(id: &str) -> Value {
json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"})
}
fn domain_request(id: &str) -> JobRequest {
let mut intent = request(id);
intent.profile = "builtin:standalone-subjektiv-consolidation".into();
intent
}
impl JobDomainProvider for RecordingDomainProvider {
fn grant(
&self,
snapshot: &JobSnapshot,
fence: Arc<dyn JobAttemptFence>,
) -> Result<worker::job::JobFeatureGrant, String> {
if snapshot.domain_grant != Some(domain_grant(&snapshot.request.job_id)) {
return Err("foreign or mismatched immutable domain grant".into());
}
self.0.lock().unwrap().push((snapshot.clone(), fence));
Ok(worker::job::JobFeatureGrant {
satisfied_requirements: vec!["feature.subjektiv"],
..Default::default()
})
}
}
async fn await_model(client: &DomainTestClient) {
tokio::time::timeout(Duration::from_secs(2), client.entered.notified())
.await
.unwrap();
}
async fn await_job(jobs: &StandaloneJobs, id: &str) -> JobSnapshot {
tokio::time::timeout(Duration::from_secs(3), jobs.wait(id))
.await
.unwrap()
.unwrap()
}
#[tokio::test]
async fn pending_domain_grants_survive_service_reopen_and_remain_immutable() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("jobs.sqlite3");
let jobs = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap();
let intent = domain_request("granted");
let grant = domain_grant("granted");
let reserved = jobs.request_granted(intent.clone(), grant.clone()).unwrap();
let root = jobs.request(request("root")).unwrap();
assert_eq!(reserved.domain_grant, Some(grant.clone()));
assert!(root.domain_grant.is_none());
assert_eq!(
jobs.request_granted(intent.clone(), grant.clone()).unwrap(),
reserved
);
assert!(matches!(
jobs.request(intent.clone()),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert!(matches!(
jobs.request_granted(intent.clone(), domain_grant("foreign")),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert!(matches!(
jobs.request_granted(request("root"), grant.clone()),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
assert_eq!(jobs.pending().unwrap().len(), 2);
jobs.shutdown().await.unwrap();
let reopened = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap();
let pending = reopened.pending().unwrap();
assert_eq!(pending.len(), 2);
assert!(pending.contains(&reserved));
assert!(pending.contains(&root));
assert_eq!(reopened.get("granted").unwrap(), reserved);
assert_eq!(
reopened
.request_granted(intent.clone(), grant.clone())
.unwrap(),
reserved
);
assert!(matches!(
reopened.request_granted(intent, json!({"different":true})),
Err(JobServiceError::Store(JobStoreError::IntentConflict(_)))
));
let provider = Arc::new(RecordingDomainProvider::default());
reopened.bind_domain(provider.clone()).unwrap();
reopened
.start_with_model_client("granted", DomainTestClient::success())
.await
.unwrap();
let completed = await_job(&reopened, "granted").await;
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.domain_grant, Some(grant));
assert_eq!(completed.result, Some(json!({"valid":true})));
let captured = provider.0.lock().unwrap()[0].0.clone();
assert_eq!(
captured, reserved,
"provider receives the persisted immutable intent and grant"
);
reopened.shutdown().await.unwrap();
}
#[tokio::test]
async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_from_profile() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let granted = jobs
.request_granted(domain_request("granted"), domain_grant("granted"))
.unwrap();
let client = DomainTestClient::success();
let error = jobs
.start_with_model_client("granted", client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(
error
.to_string()
.contains("unavailable explicit domain capability"),
"{error}"
);
assert_eq!(jobs.get("granted").unwrap(), granted);
assert!(client.requests.lock().unwrap().is_empty());
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
assert!(matches!(
jobs.bind_domain(provider.clone()),
Err(JobServiceError::Configuration(_))
));
let ungranted = jobs.request(domain_request("ungranted")).unwrap();
let error = jobs
.start_with_model_client("ungranted", client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(error.to_string().contains("feature.subjektiv"), "{error}");
assert_eq!(jobs.get("ungranted").unwrap(), ungranted);
assert!(
provider.0.lock().unwrap().is_empty(),
"bound provider is not a profile-derived grant"
);
assert!(client.requests.lock().unwrap().is_empty());
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
for (id, grant) in [
("foreign-job", domain_grant("another-job")),
(
"foreign-revision",
json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}),
),
(
"foreign-consumer",
json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}),
),
] {
let reserved = jobs.request_granted(domain_request(id), grant).unwrap();
let client = DomainTestClient::success();
let error = jobs
.start_with_model_client(id, client.clone())
.await
.unwrap_err();
assert!(matches!(error, JobServiceError::Configuration(_)));
assert!(
error.to_string().contains("foreign or mismatched"),
"{error}"
);
assert_eq!(jobs.get(id).unwrap(), reserved);
assert!(client.requests.lock().unwrap().is_empty());
}
assert!(provider.0.lock().unwrap().is_empty());
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn domain_fence_is_live_only_for_current_dispatched_attempt_and_revoked_on_cancel() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
let reserved = jobs
.request_granted(domain_request("fenced"), domain_grant("fenced"))
.unwrap();
let failure_release = Arc::new(tokio::sync::Notify::new());
let first = DomainTestClient {
failure_release: Some(failure_release.clone()),
..Default::default()
};
jobs.start_with_model_client("fenced", first.clone())
.await
.unwrap();
await_model(&first).await;
let old_fence = provider.0.lock().unwrap()[0].1.clone();
old_fence.ensure_live().unwrap();
failure_release.notify_one();
let failed = await_job(&jobs, "fenced").await;
assert_eq!(failed.state, JobState::Failed);
assert!(failed.result.is_none());
assert!(old_fence.ensure_live().is_err());
// Only known failure permits a retry; cancellation remains terminal.
let retried = jobs.retry("fenced").unwrap();
assert_ne!(retried.attempt.attempt_id, reserved.attempt.attempt_id);
assert_eq!(retried.domain_grant, reserved.domain_grant);
assert!(old_fence.ensure_live().is_err());
let second = DomainTestClient {
stall: true,
..Default::default()
};
jobs.start_with_model_client("fenced", second.clone())
.await
.unwrap();
await_model(&second).await;
let current_fence = provider.0.lock().unwrap()[1].1.clone();
current_fence.ensure_live().unwrap();
assert!(
old_fence.ensure_live().is_err(),
"a new attempt must not revive a stale capability"
);
let cancelled = jobs.cancel("fenced").unwrap();
assert_eq!(cancelled.state, JobState::Cancelled);
assert!(
current_fence.ensure_live().is_err(),
"durable cancellation revokes current domain operations immediately"
);
assert!(old_fence.ensure_live().is_err());
let cleaned = await_job(&jobs, "fenced").await;
assert_eq!(cleaned.state, JobState::Cancelled);
assert!(cleaned.result.is_none());
assert!(
jobs.retry("fenced").is_err(),
"cancelled intent is not a known retryable failure"
);
jobs.shutdown().await.unwrap();
assert!(old_fence.ensure_live().is_err());
assert!(current_fence.ensure_live().is_err());
}
#[tokio::test]
async fn bound_domain_does_not_change_root_generic_result_only_execution() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let provider = Arc::new(RecordingDomainProvider::default());
jobs.bind_domain(provider.clone()).unwrap();
let root = jobs.request(request("root")).unwrap();
assert!(root.domain_grant.is_none());
let client = DomainTestClient::success();
jobs.start_with_model_client("root", client.clone())
.await
.unwrap();
let completed = await_job(&jobs, "root").await;
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.result, Some(json!({"valid":true})));
assert!(completed.domain_grant.is_none());
assert!(provider.0.lock().unwrap().is_empty());
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
for sent in requests.iter() {
assert_eq!(
sent.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
["SubmitJobResult"]
);
assert_eq!(sent.config.max_tokens, Some(worker::job::MAX_JOB_TOKENS));
}
drop(requests);
jobs.shutdown().await.unwrap();
}
#[tokio::test]
async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let first = jobs.request(request("first")).unwrap();
let second = jobs.request(request("second")).unwrap();
for snapshot in [&first, &second] {
jobs.with_store(|store| {
store.dispatch(&snapshot.request.job_id, &snapshot.attempt.attempt_id, 8)
})
.unwrap();
}
let bound = sink(&jobs, "first", &first.attempt.attempt_id);
assert!(
bound
.submit(result("second", &second.attempt.attempt_id))
.is_err()
);
assert!(bound.submit(result("first", "first:attempt:2")).is_err());
let mut stale = result("first", &first.attempt.attempt_id);
stale.input_revision = "r0".into();
assert!(bound.submit(stale).is_err());
let mut expired = sink(&jobs, "first", &first.attempt.attempt_id);
expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
assert!(
expired
.submit(result("first", &first.attempt.attempt_id))
.is_err()
);
assert!(jobs.get("first").unwrap().result.is_none());
bound
.submit(result("first", &first.attempt.attempt_id))
.unwrap();
// Accepted exact replay is still allowed after the deadline, never a new result.
expired
.submit(result("first", &first.attempt.attempt_id))
.unwrap();
let mut changed = result("first", &first.attempt.attempt_id);
changed.result = serde_json::json!({"valid":false});
assert!(expired.submit(changed).is_err());
assert_eq!(jobs.get("second").unwrap().state, JobState::Pending);
jobs.shutdown().await.unwrap();
assert!(
bound
.submit(result("first", &first.attempt.attempt_id))
.is_err()
);
}
#[tokio::test]
async fn cleanup_cannot_reclassify_success_or_durable_cancellation_and_stale_handles_are_closed() {
let temp = tempfile::tempdir().unwrap();
let jobs =
StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap();
let cancelled = jobs.request(request("cancelled")).unwrap();
jobs.cancel("cancelled").unwrap();
finish_if_pending(
&jobs.store,
"cancelled",
&cancelled.attempt.attempt_id,
JobAttemptState::Unknown,
"cleanup",
)
.unwrap();
assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled);
let accepted = jobs.request(request("accepted")).unwrap();
jobs.with_store(|store| store.dispatch("accepted", &accepted.attempt.attempt_id, 8))
.unwrap();
sink(&jobs, "accepted", &accepted.attempt.attempt_id)
.submit(result("accepted", &accepted.attempt.attempt_id))
.unwrap();
finish_if_pending(
&jobs.store,
"accepted",
&accepted.attempt.attempt_id,
JobAttemptState::Failed,
"model failed later",
)
.unwrap();
assert_eq!(jobs.get("accepted").unwrap().state, JobState::Completed);
jobs.shutdown().await.unwrap();
assert!(jobs.store.lock().unwrap().is_none());
for operation in [
jobs.cancel("cancelled"),
jobs.acknowledge("accepted"),
jobs.retry("cancelled"),
jobs.request(request("new")),
] {
assert!(matches!(operation, Err(JobServiceError::Closed)));
}
}
+32
View File
@@ -15,6 +15,7 @@ pub struct StandaloneLaunchConfig {
pub state_dir: PathBuf,
pub profile: ProfileSelector,
pub worker_name: String,
pub subject_id: Option<String>,
}
pub struct ResolvedStandaloneLaunch {
@@ -22,6 +23,7 @@ pub struct ResolvedStandaloneLaunch {
pub state_dir: PathBuf,
pub profile: ResolvedProfile,
pub prompt_catalog: PromptCatalogSource,
pub subject_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
@@ -32,6 +34,10 @@ pub enum StandaloneLaunchError {
PathProfileUnsupported,
#[error("the standalone profile could not be resolved")]
ProfileResolutionFailed,
#[error(
"local Subject selection requires an enabled, non-consolidator subjektiv Profile and canonical Subject id"
)]
InvalidSubjectPolicy,
}
impl StandaloneLaunchConfig {
@@ -46,9 +52,15 @@ impl StandaloneLaunchConfig {
state_dir: state_dir.into(),
profile,
worker_name: worker_name.into(),
subject_id: None,
}
}
pub fn with_subject(mut self, subject_id: String) -> Self {
self.subject_id = Some(subject_id);
self
}
/// Resolve only built-in/XDG profile authority and bind standalone scope
/// to the canonical process cwd. Repository-local profile discovery is
/// deliberately not part of this path.
@@ -74,12 +86,32 @@ impl StandaloneLaunchConfig {
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
if let Some(id) = &self.subject_id {
if !crate::subjektiv::valid_subject_id(id)
|| !profile.manifest.feature.subjektiv.profile.enabled
|| profile
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| profile
.manifest
.feature
.subjektiv
.workspace_settings
.is_some()
{
return Err(StandaloneLaunchError::InvalidSubjectPolicy);
}
}
Ok(ResolvedStandaloneLaunch {
cwd,
state_dir: self.state_dir,
profile,
prompt_catalog: PromptCatalogSource::builtins_only(),
subject_id: self.subject_id,
})
}
}
+4
View File
@@ -5,8 +5,12 @@
//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path.
pub mod host;
mod job_store;
pub mod jobs;
pub mod launch;
pub mod store;
pub mod subjektiv;
mod subjektiv_sessions;
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
+38
View File
@@ -87,6 +87,9 @@ pub struct StandaloneWorkerRecord {
pub storage_key: String,
pub cwd: StandaloneCwdIdentity,
pub manifest: WorkerManifest,
/// Original explicit local Subject/storage binding; never inferred from policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
pub active_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_segment_id: Option<SegmentId>,
@@ -127,6 +130,16 @@ impl StandaloneWorkerStore {
Ok(Self { root })
}
/// Observational constructor: never creates a missing root or repairs state.
pub fn open_existing(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
let root = root.into();
let metadata = fs::symlink_metadata(&root).map_err(StandaloneStoreError::Io)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(StandaloneStoreError::NotDirectory);
}
Ok(Self { root })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
@@ -158,6 +171,25 @@ impl StandaloneWorkerStore {
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
self.commit_created_connected(
allocation,
manifest,
storage_key,
active_session_id,
active_segment_id,
None,
)
}
pub(crate) fn commit_created_connected(
&self,
allocation: &StandaloneWorkerAllocation,
manifest: WorkerManifest,
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let now = now_unix_ms()?;
let record = StandaloneWorkerRecord {
@@ -168,6 +200,7 @@ impl StandaloneWorkerStore {
storage_key,
cwd: allocation.cwd.clone(),
manifest,
subject,
active_session_id,
active_segment_id,
status: StandaloneWorkerStatus::Active,
@@ -352,6 +385,10 @@ impl StandaloneWorkerStore {
}
#[must_use]
pub(crate) fn jobs_path(&self, id: WorkerId) -> PathBuf {
self.worker_dir(id).join("jobs.sqlite3")
}
pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
self.worker_dir(id).join("runtime")
}
@@ -789,6 +826,7 @@ permission = "write"
inode: None,
},
manifest,
subject: None,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
use super::*;
#[test]
fn catalog_reopen_preserves_random_scope_subject_and_behavior() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("state");
let catalog = StandaloneSubjects::open(&root).unwrap();
let subject = catalog
.create(
"continuing-owner",
Some("Keep user behavior separate from Memory."),
)
.unwrap();
let scope = catalog.scope_id().to_owned();
assert!(uuid::Uuid::parse_str(&scope).is_ok());
assert!(uuid::Uuid::parse_str(subject.id.strip_prefix("subject-").unwrap()).is_ok());
assert_ne!(scope, subject.id);
drop(catalog);
let reopened = StandaloneSubjects::open_existing(&root).unwrap();
assert_eq!(reopened.scope_id(), scope);
assert_eq!(reopened.list().unwrap(), vec![subject]);
}
#[test]
fn implicit_open_does_not_create_subject_storage() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("missing");
assert!(StandaloneSubjects::open_existing(&root).is_err());
assert!(!root.exists());
assert!(StandaloneSubjects::open_existing(temp.path()).is_err());
assert!(!temp.path().join("subjektiv").exists());
}
#[test]
fn subject_kernel_lease_is_not_worker_or_profile_scoped() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let a = catalog.create("a", None).unwrap();
let b = catalog.create("b", None).unwrap();
let mut first = catalog
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
assert!(matches!(
StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7()),
Err(SubjectError::Active(_))
));
let mut second = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&b.id, protocol::WorkerId::now_v7())
.unwrap();
first.close().unwrap();
let mut replacement = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
replacement.close().unwrap();
second.close().unwrap();
}
#[test]
fn closed_capabilities_and_foreign_worker_context_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let subject = catalog.create("owner", None).unwrap();
let id = protocol::WorkerId::now_v7();
let mut connection = catalog.connect(&subject.id, id).unwrap();
let port = connection.host.clone();
let foreign = SubjektivHostContext {
worker_id: protocol::WorkerId::now_v7().to_string(),
session_id: session_store::SessionId::now_v7().to_string(),
};
let read = SubjektivMemoryBackendOperation::ResidentContext(
memory::backend::MemoryResidentSummaryOperation {},
);
assert!(port.memory(&foreign, read.clone()).is_err());
let owner = SubjektivHostContext {
worker_id: format!("standalone-{id}"),
session_id: session_store::SessionId::now_v7().to_string(),
};
assert!(port.memory(&owner, read.clone()).is_ok());
assert!(
port.record_session(&owner, false).is_err(),
"unattributed Session is not read authority"
);
connection.close().unwrap();
assert!(port.memory(&owner, read).is_err());
}
#[cfg(unix)]
#[test]
fn scope_rejects_symlinks_and_non_private_roots() {
use std::os::unix::fs::{PermissionsExt, symlink};
let temp = tempfile::tempdir().unwrap();
let alias = temp.path().join("alias");
let real = temp.path().join("real");
fs::create_dir(&real).unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap();
symlink(&real, &alias).unwrap();
assert!(StandaloneSubjects::open(&alias).is_err());
let public = temp.path().join("public");
fs::create_dir(&public).unwrap();
fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap();
assert!(StandaloneSubjects::open(&public).is_err());
let catalog = StandaloneSubjects::open(&real).unwrap();
drop(catalog);
let scope = real.join("subjektiv/scope.json");
let original = fs::read(&scope).unwrap();
fs::remove_file(&scope).unwrap();
let foreign = temp.path().join("foreign.json");
fs::write(&foreign, &original).unwrap();
symlink(&foreign, &scope).unwrap();
assert!(StandaloneSubjects::open_existing(&real).is_err());
assert_eq!(fs::read(&foreign).unwrap(), original);
}
#[test]
fn different_local_roots_do_not_authorize_shared_subject_ids() {
let temp = tempfile::tempdir().unwrap();
let first = StandaloneSubjects::open(temp.path().join("first")).unwrap();
let subject = first.create("owner", None).unwrap();
let other = StandaloneSubjects::open(temp.path().join("other")).unwrap();
assert_ne!(first.scope_id(), other.scope_id());
assert!(
other
.connect(&subject.id, protocol::WorkerId::now_v7())
.is_err()
);
}
File diff suppressed because it is too large Load Diff
+478
View File
@@ -0,0 +1,478 @@
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::llm_client::{
LlmClient,
error::ClientError,
event::{Event as LlmEvent, StopReason},
types::Request,
};
use async_trait::async_trait;
use futures::{Stream, stream};
use job::{JobLimits, JobRequest};
use standalone::jobs::{JobServiceError, JobState};
use standalone::{StandaloneHost, StandaloneLaunchConfig};
#[derive(Clone)]
struct ScriptedClient {
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
requests: Arc<Mutex<Vec<Request>>>,
started: Arc<tokio::sync::Notify>,
stall: bool,
fail: bool,
}
impl ScriptedClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses.into())),
requests: Arc::new(Mutex::new(Vec::new())),
started: Arc::new(tokio::sync::Notify::new()),
stall: false,
fail: false,
}
}
fn stall() -> Self {
Self {
stall: true,
..Self::new(Vec::new())
}
}
fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
}
#[async_trait]
impl LlmClient for ScriptedClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.requests.lock().unwrap().push(request);
self.started.notify_one();
if self.fail {
return Err(ClientError::Config("scripted model failure".into()));
}
if self.stall {
return Ok(Box::pin(stream::pending()));
}
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.expect("scripted response");
Ok(Box::pin(stream::iter(events.into_iter().map(Ok))))
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
fn prose() -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "done"),
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
]
}
fn success() -> ScriptedClient {
ScriptedClient::new(vec![
vec![
LlmEvent::tool_use_start(0, "result-1", "SubmitJobResult"),
LlmEvent::tool_input_delta(0, r#"{"result":{"valid":true}}"#),
LlmEvent::tool_use_stop(0),
],
prose(),
])
}
fn request(id: &str) -> JobRequest {
JobRequest {
job_id: id.into(),
purpose: "generic_test".into(),
input_revision: "r1".into(),
input_ref: "test:immutable".into(),
input: serde_json::json!({"test_input":42}),
instruction: "Return a structured validity result".into(),
profile: "builtin:job".into(),
serialization_key: None,
limits: JobLimits::default(),
}
}
async fn host(temp: &tempfile::TempDir) -> (StandaloneHost, ScriptedClient) {
let launch = StandaloneLaunchConfig::new(
temp.path(),
temp.path().join("state"),
manifest::ProfileSelector::Default,
"job-host",
)
.resolve()
.unwrap();
let client = ScriptedClient::new(Vec::new());
let host = StandaloneHost::start_with_model_client(launch, client.clone())
.await
.unwrap();
(host, client)
}
async fn wait_started(client: &ScriptedClient) {
tokio::time::timeout(Duration::from_secs(10), client.started.notified())
.await
.unwrap();
}
#[tokio::test]
async fn host_job_runs_without_dialog_and_result_survives_restore_and_ack() {
let temp = tempfile::tempdir().unwrap();
let (host, dialog) = host(&temp).await;
let id = host.worker_id();
let jobs = host.jobs();
let intent = request("success");
let reserved = jobs.request(intent.clone()).unwrap();
assert_eq!(jobs.request(intent.clone()).unwrap(), reserved);
let model = success();
jobs.start_with_model_client("success", model.clone())
.await
.unwrap();
let completed = jobs.wait("success").await.unwrap();
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.result, Some(serde_json::json!({"valid":true})));
assert!(!completed.acknowledged);
let neutral = completed.outcome();
assert_eq!(neutral.state, job::JobState::Completed);
assert_eq!(neutral.result, completed.result);
assert_eq!(neutral.attempt_state, job::JobAttemptState::Completed);
assert!(
dialog.requests().is_empty(),
"Job never runs on the interactive model/session"
);
assert_eq!(
model.requests()[0]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
["SubmitJobResult"]
);
assert_eq!(jobs.request(intent.clone()).unwrap(), completed);
assert!(
jobs.start_with_model_client("success", success())
.await
.is_err()
);
assert!(jobs.retry("success").is_err());
let db = temp
.path()
.join("state")
.join(id.to_string())
.join("jobs.sqlite3");
assert!(db.exists());
host.shutdown().await.unwrap();
assert!(matches!(
jobs.request(request("after-shutdown")),
Err(JobServiceError::Closed)
));
assert!(matches!(
jobs.cancel("success"),
Err(JobServiceError::Closed)
));
assert!(matches!(
jobs.acknowledge("success"),
Err(JobServiceError::Closed)
));
assert!(matches!(
jobs.start_with_model_client("success", success()).await,
Err(JobServiceError::Closed)
));
let restored = StandaloneHost::restore_with_model_client(
temp.path().join("state"),
id,
ScriptedClient::new(Vec::new()),
)
.await
.unwrap();
let restored_jobs = restored.jobs();
assert_eq!(restored_jobs.get("success").unwrap(), completed);
assert!(restored_jobs.acknowledge("success").unwrap().acknowledged);
restored.shutdown().await.unwrap();
}
#[tokio::test]
async fn live_resource_limits_cancellation_and_wait_are_host_owned() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let jobs = host.jobs();
let mut first = request("first");
first.limits.max_concurrent_jobs = 1;
jobs.request(first).unwrap();
jobs.request(request("second")).unwrap();
let stalled = ScriptedClient::stall();
jobs.start_with_model_client("first", stalled.clone())
.await
.unwrap();
wait_started(&stalled).await;
// Waiting must not remove the Host-owned task/cancellation capability.
let waiting_jobs = jobs.clone();
let waiter = tokio::spawn(async move { waiting_jobs.wait("first").await });
assert!(matches!(
jobs.start_with_model_client("second", success()).await,
Err(JobServiceError::Capacity)
));
jobs.cancel("first").unwrap();
let cancelled = tokio::time::timeout(Duration::from_secs(10), waiter)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(cancelled.state, JobState::Cancelled);
assert!(jobs.retry("first").is_err());
jobs.start_with_model_client("second", success())
.await
.unwrap();
assert_eq!(
jobs.wait("second").await.unwrap().state,
JobState::Completed
);
host.shutdown().await.unwrap();
}
#[tokio::test]
async fn prose_failure_requires_explicit_bounded_new_attempt_and_exact_replay_does_not_retry() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let jobs = host.jobs();
let intent = request("prose");
jobs.request(intent.clone()).unwrap();
jobs.start_with_model_client("prose", ScriptedClient::new(vec![prose()]))
.await
.unwrap();
let failed = jobs.wait("prose").await.unwrap();
assert_eq!(failed.state, JobState::Failed);
assert!(failed.result.is_none());
assert_eq!(jobs.request(intent).unwrap(), failed);
let retry = jobs.retry("prose").unwrap();
assert_eq!(retry.attempt.number, 2);
assert_ne!(retry.attempt.attempt_id, failed.attempt.attempt_id);
jobs.start_with_model_client("prose", ScriptedClient::new(vec![prose()]))
.await
.unwrap();
assert_eq!(jobs.wait("prose").await.unwrap().state, JobState::Failed);
assert!(jobs.retry("prose").is_err());
host.shutdown().await.unwrap();
}
#[tokio::test]
async fn timeout_is_unknown_not_prose_success_or_automatic_retry() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let jobs = host.jobs();
let mut intent = request("timeout");
intent.limits.timeout_seconds = 1;
jobs.request(intent).unwrap();
let stalled = ScriptedClient::stall();
jobs.start_with_model_client("timeout", stalled.clone())
.await
.unwrap();
wait_started(&stalled).await;
let outcome = tokio::time::timeout(Duration::from_secs(10), jobs.wait("timeout"))
.await
.unwrap()
.unwrap();
assert_eq!(outcome.state, JobState::Unknown);
assert!(outcome.result.is_none());
assert!(jobs.retry("timeout").is_err());
host.shutdown().await.unwrap();
}
#[tokio::test]
async fn shutdown_keeps_reserved_results_and_fences_running_attempts_on_reopen() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let id = host.worker_id();
let jobs = host.jobs();
jobs.request(request("reserved")).unwrap();
jobs.request(request("cancelled")).unwrap();
jobs.cancel("cancelled").unwrap();
jobs.request(request("running")).unwrap();
let stalled = ScriptedClient::stall();
jobs.start_with_model_client("running", stalled.clone())
.await
.unwrap();
wait_started(&stalled).await;
host.shutdown().await.unwrap();
assert!(matches!(jobs.get("running"), Err(JobServiceError::Closed)));
let restored = StandaloneHost::restore_with_model_client(
temp.path().join("state"),
id,
ScriptedClient::new(Vec::new()),
)
.await
.unwrap();
let jobs = restored.jobs();
assert_eq!(
jobs.pending()
.unwrap()
.iter()
.map(|snapshot| snapshot.request.job_id.as_str())
.collect::<Vec<_>>(),
["reserved"]
);
assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled);
assert_eq!(jobs.get("running").unwrap().state, JobState::Unknown);
assert!(jobs.retry("running").is_err());
jobs.start_with_model_client("reserved", success())
.await
.unwrap();
assert_eq!(
jobs.wait("reserved").await.unwrap().state,
JobState::Completed
);
restored.shutdown().await.unwrap();
}
#[tokio::test]
async fn profile_requirements_are_rejected_without_model_execution_or_implicit_fallback() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let jobs = host.jobs();
for profile in [
"builtin:coder",
"builtin:backend-job",
"builtin:does-not-exist",
] {
let mut intent = request(profile);
intent.profile = profile.into();
jobs.request(intent).unwrap();
let model = success();
assert!(matches!(
jobs.start_with_model_client(profile, model.clone()).await,
Err(JobServiceError::Configuration(_))
));
assert!(model.requests().is_empty());
assert_eq!(jobs.get(profile).unwrap().state, JobState::Pending);
}
host.shutdown().await.unwrap();
}
// Only this test harness starts a subprocess: production Jobs remain in-process.
// The child exits deliberately without destructors to prove actual process-loss
// recovery, rather than treating a hand-edited SQLite fixture as that evidence.
#[tokio::test]
#[ignore = "entry point for process_loss_recovers_durable_jobs_without_replaying_unknown"]
async fn process_job_fixture() {
let root = std::path::PathBuf::from(
std::env::var_os("YOI_JOB_PROCESS_FIXTURE_DIR").expect("fixture root"),
);
let launch = StandaloneLaunchConfig::new(
&root,
root.join("state"),
manifest::ProfileSelector::Default,
"job-process-fixture",
)
.resolve()
.unwrap();
let host = StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new()))
.await
.unwrap();
std::fs::write(root.join("worker-id"), host.worker_id().to_string()).unwrap();
let jobs = host.jobs();
jobs.request(request("reserved")).unwrap();
jobs.request(request("cancelled")).unwrap();
jobs.cancel("cancelled").unwrap();
jobs.request(request("accepted")).unwrap();
jobs.start_with_model_client("accepted", success())
.await
.unwrap();
assert_eq!(
jobs.wait("accepted").await.unwrap().state,
JobState::Completed
);
jobs.request(request("interrupted")).unwrap();
let stalled = ScriptedClient::stall();
jobs.start_with_model_client("interrupted", stalled.clone())
.await
.unwrap();
wait_started(&stalled).await;
// Test-owned process only, never the operating Yoi Worker or dogfood daemon.
std::process::exit(23);
}
#[tokio::test]
async fn process_loss_recovers_durable_jobs_without_replaying_unknown() {
let temp = tempfile::tempdir().unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "process_job_fixture", "--ignored", "--nocapture"])
.env("YOI_JOB_PROCESS_FIXTURE_DIR", temp.path())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(23),
"fixture failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let worker_id = std::fs::read_to_string(temp.path().join("worker-id"))
.unwrap()
.parse()
.unwrap();
let host = StandaloneHost::restore_with_model_client(
temp.path().join("state"),
worker_id,
ScriptedClient::new(Vec::new()),
)
.await
.unwrap();
let jobs = host.jobs();
assert_eq!(jobs.get("interrupted").unwrap().state, JobState::Unknown);
assert!(jobs.retry("interrupted").is_err());
let completed = jobs.get("accepted").unwrap();
assert_eq!(completed.state, JobState::Completed);
assert_eq!(completed.result, Some(serde_json::json!({"valid":true})));
assert!(!completed.acknowledged);
assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled);
assert_eq!(jobs.pending().unwrap().len(), 1);
jobs.start_with_model_client("reserved", success())
.await
.unwrap();
assert_eq!(
jobs.wait("reserved").await.unwrap().state,
JobState::Completed
);
host.shutdown().await.unwrap();
}
#[tokio::test]
async fn model_failure_is_durable_known_failure_with_explicit_successful_retry() {
let temp = tempfile::tempdir().unwrap();
let (host, _) = host(&temp).await;
let jobs = host.jobs();
jobs.request(request("model-failure")).unwrap();
let client = ScriptedClient {
fail: true,
..ScriptedClient::new(Vec::new())
};
jobs.start_with_model_client("model-failure", client)
.await
.unwrap();
let failure = jobs.wait("model-failure").await.unwrap();
assert_eq!(failure.state, JobState::Failed);
assert!(failure.result.is_none());
assert!(
failure
.attempt
.failure
.unwrap()
.contains("scripted model failure")
);
let next = jobs.retry("model-failure").unwrap();
assert_eq!(next.attempt.number, 2);
jobs.start_with_model_client("model-failure", success())
.await
.unwrap();
assert_eq!(
jobs.wait("model-failure").await.unwrap().state,
JobState::Completed
);
host.shutdown().await.unwrap();
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "subjektiv"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
feature-storage = { path = "../feature-storage" }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
memory.workspace = true
manifest.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
server-api.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+76
View File
@@ -0,0 +1,76 @@
# subjektiv
Shared Subject-scoped Memory domain and model-facing operations. This crate has
no Workspace Server dependency. `server-api` is the existing transport-neutral
request/response contract crate, not an authentication or Server implementation.
## Storage scope
The trusted Host creates `feature_storage::FeatureStorage`, selects
`FeatureStorage::scope(scope_id)`, registers `REGISTRATION` (or uses
`SubjektivStore::register`), and opens `SubjektivStore::open`. A trusted managed
`FeatureDatabase` can also be opened with `SubjektivStore::from_database`.
`scope_id` identifies the physical storage sharing boundary. Backend uses its
existing Workspace ID. Standalone uses a persisted random local storage-scope
identity under its operator-managed state directory, **not a fake Workspace**.
Subject IDs are independently issued random domain identities. An ID is a lookup
key, never an authorization credential.
Existing SQL schema and migrations are retained. In particular,
`store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
to be checked against the physical scope; local provenance need not invent a
Workspace value. `workspace_id()` is a Backend compatibility alias for
`scope_id()`.
## Host operation boundary
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
nested provenance projections, immutable revision reads, proposal validation,
explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections.
The Host context is not serializable or model input. Trusted Rust Hosts construct
it only after validating their external execution authority:
- `HostOperationContext::validated_body` binds one Subject and optionally an
existing immutable `SubjectSessionAttribution`. Explicit staging requires the
request Session to match that attribution.
- `HostOperationContext::validated_consolidation` binds one Subject, an optional
immutable candidate batch, and an optional `JobAttemptBinding`. Attenuated Jobs
must supply `Some(batch)`, including an empty batch; `None` is legacy non-Job
Host consolidation authority. Body-only operations are unavailable to
consolidation contexts, and candidate reads/decisions cannot escape the batch.
These constructors validate store/domain bindings, not external authentication.
Backend retains authenticated Runtime/Worker identity, current singleton and
live Job attempt checks, candidate grant checks, and public Session validation.
Local Hosts retain exclusive Subject execution leases, generic Job admission,
Worker/Session ownership, and committed public Session evidence validation.
Before explicit staging, the Host resolves evidence using its committed public
Session projection; the common API does not inspect Session files, hidden
reasoning, system prompts, or arbitrary paths. Lower-level repository/projection
functions are trusted Rust APIs, not model capabilities.
## Common consolidation Job rules
`job::bounded_candidate_batch(ids, max_result_bytes)` selects the exact T-704
ordered-prefix budget (100 candidates maximum, 2,048-byte envelope, twice each
JSON-escaped ID size plus 128 bytes per disposition).
`job::consolidation_required(count, bytes, force, availability)` owns the shared
trigger thresholds and empty-ready/surface-retry rules.
`job::validate_result(store, subject_id, batch, job_id, attempt_id, value)` checks
the strict result shape, exact admitted Subject/batch, durable dispositions, and
the persisted terminal surface outcome from that attempt. After validation,
`job::with_dispositions(store, subject_id, batch, value)` adds the deterministic
compact disposition projection. Hosts retain Job result binding/admission; these
helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts.
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
//! Trusted Host operation context, never deserialized from model input.
use super::{OperationError, OperationResult, subjektiv_store_error};
use crate::{SubjectSessionAttribution, SubjektivStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobAttemptBinding {
pub job_id: String,
pub attempt_id: String,
}
#[derive(Debug, Clone)]
enum Authority {
Body {
attribution: Option<SubjectSessionAttribution>,
},
Consolidation {
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
},
}
/// A capability issued by trusted Rust Host code after authentication/lease,
/// delegation, and committed Session evidence checks. Not a request DTO.
/// Constructors check domain binding, not external identity or execution leases.
/// Backend must retain its authenticated Worker and active Job grant checks;
/// standalone must hold the selected Subject lease and attenuate Job batches.
#[derive(Debug, Clone)]
pub struct HostOperationContext {
scope_id: String,
subject_id: String,
authority: Authority,
}
impl HostOperationContext {
/// The Host has validated body execution authority. Attribution, when supplied,
/// must already be immutable history in this store. Before StageExplicit the
/// Host must resolve/validate all evidence against committed public Session
/// entries; neither this constructor nor the operation reads Session files.
pub fn validated_body(
store: &SubjektivStore,
subject_id: &str,
attribution: Option<SubjectSessionAttribution>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(record) = &attribution {
if record.subject_id != subject_id
|| store
.session_attribution(&record.session_id)
.map_err(subjektiv_store_error)?
.as_ref()
!= Some(record)
{
return Err(OperationError::PermissionDenied(
"Session attribution does not match the Host subject".into(),
));
}
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Body { attribution },
})
}
/// The Host has validated consolidation authority and (for a Job) its active
/// attempt. `Some(candidate_ids)` is the immutable admitted batch; no candidate
/// outside it is accessible. `None` retains Backend's non-Job consolidation
/// authority, and must not be issued to an attenuated Job.
pub fn validated_consolidation(
store: &SubjektivStore,
subject_id: &str,
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(ids) = &candidate_ids {
for id in ids {
if store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"candidate_not_found: {id}"
)));
}
}
}
if job.as_ref().is_some_and(|binding| {
binding.job_id.trim().is_empty() || binding.attempt_id.trim().is_empty()
}) {
return Err(OperationError::InvalidInput(
"Job attempt binding must not be empty".into(),
));
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Consolidation { candidate_ids, job },
})
}
fn require_subject(store: &SubjektivStore, id: &str) -> OperationResult<()> {
store
.subject(id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(id.into()))?;
Ok(())
}
pub fn subject_id(&self) -> &str {
&self.subject_id
}
pub(super) fn require_store(&self, store: &SubjektivStore) -> OperationResult<()> {
if self.scope_id != store.scope_id() {
return Err(OperationError::PermissionDenied(
"Host operation context belongs to another storage scope".into(),
));
}
Ok(())
}
pub(super) fn body(&self) -> OperationResult<Option<&SubjectSessionAttribution>> {
match &self.authority {
Authority::Body { attribution } => Ok(attribution.as_ref()),
_ => Err(OperationError::PermissionDenied(
"operation requires Subject body authority".into(),
)),
}
}
pub(super) fn consolidation(
&self,
) -> OperationResult<(Option<&[String]>, Option<&JobAttemptBinding>)> {
match &self.authority {
Authority::Consolidation { candidate_ids, job } => {
Ok((candidate_ids.as_deref(), job.as_ref()))
}
_ => Err(OperationError::PermissionDenied(
"operation requires consolidation authority".into(),
)),
}
}
pub(super) fn candidate(&self, id: &str) -> OperationResult<()> {
let (ids, _) = self.consolidation()?;
if ids.is_some_and(|ids| !ids.iter().any(|allowed| allowed == id)) {
return Err(OperationError::PermissionDenied(
"candidate is outside the admitted Job batch".into(),
));
}
Ok(())
}
}
+255
View File
@@ -0,0 +1,255 @@
use super::*;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
/// Executes the same domain operations and bounded projections for every Host.
/// The caller authenticates and validates evidence before issuing `context`.
pub fn execute(
store: &crate::SubjektivStore,
context: &HostOperationContext,
operation: Op,
) -> OperationResult<Response> {
context.require_store(store)?;
let subject_id = context.subject_id();
Ok(match operation {
Op::ResidentSummary(_) => {
context.body()?;
Response::ResidentSummary(resident_summary_output(
store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?,
))
}
Op::ResidentContext(_) => {
context.body()?;
let resident = store
.resident_context(subject_id)
.map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface),
})
}
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
}
Op::ValidateProposal(input) => {
context.body()?;
Response::ProposalValidated(subjektiv_memory_validate_proposal(
store, subject_id, input,
)?)
}
Op::ReceiptStatus(input) => {
context.body()?;
validate_subjektiv_receipt_id(&input.receipt_id)?;
let candidate = store
.staging_candidate(subject_id, &input.receipt_id)
.map_err(subjektiv_store_error)?;
Response::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusResponse {
receipt_id: input.receipt_id,
status: if candidate.is_some() {
server_api::SubjektivMemoryReceiptStatus::Staged
} else {
server_api::SubjektivMemoryReceiptStatus::Missing
},
candidate_id: candidate.map(|record| record.id),
})
}
Op::StageExplicit(input) => {
let attribution = context.body()?.ok_or_else(|| {
OperationError::PermissionDenied(
"explicit staging requires validated Session attribution".into(),
)
})?;
if input.session_id != attribution.session_id {
return Err(OperationError::PermissionDenied(
"explicit staging Session does not match Host attribution".into(),
));
}
Response::Staged(subjektiv_memory_stage_explicit(
store,
input,
attribution.clone(),
)?)
}
Op::ListCandidates(input) => {
let (ids, _) = context.consolidation()?;
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let mut candidates = if let Some(ids) = ids {
let mut pending = Vec::new();
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
if let Some(record) = store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
{
pending.push(record);
}
}
}
pending
} else {
store
.pending_staging_candidates(subject_id, limit.saturating_add(1))
.map_err(subjektiv_store_error)?
};
let has_more = candidates.len() > limit;
candidates.truncate(limit);
Response::Candidates(server_api::SubjektivMemoryCandidateListResponse {
items: candidates
.into_iter()
.map(subjektiv_candidate_summary)
.collect(),
has_more,
})
}
Op::ReadCandidate(input) => {
context.candidate(&input.candidate_id)?;
let candidate = store
.staging_candidate(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
OperationError::InvalidInput(format!(
"candidate_not_found: pending candidate `{}` was not found",
input.candidate_id
))
})?;
if store
.staging_resolution(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.is_some()
{
return Err(OperationError::Conflict(format!(
"candidate_resolved: candidate `{}` is already resolved",
input.candidate_id
)));
}
Response::Candidate(subjektiv_candidate(candidate))
}
Op::DecideCandidate(input) => {
context.candidate(&input.candidate_id)?;
let receipt = store
.decide_candidate(subject_id, subjektiv_candidate_decision(input)?)
.map_err(subjektiv_store_error)?;
Response::CandidateDecided(subjektiv_candidate_decision_response(receipt))
}
Op::PrepareSurface(_) => {
let (ids, job) = context.consolidation()?;
if let Some(ids) = ids {
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"surface generation requires a durable disposition for batch candidate `{id}`"
)));
}
}
}
let generation = store
.prepare_job_surface_generation(
subject_id,
job.map(|b| (b.job_id.as_str(), b.attempt_id.as_str())),
)
.map_err(subjektiv_store_error)?;
let resident = store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?;
let mut response = subjektiv_surface_prepare_response(generation);
if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident
.snapshot
.filter(|s| s.built_from_store_revision == response.store_revision)
.map(|s| s.id);
}
Response::SurfacePrepared(response)
}
Op::PublishSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let points = input
.points
.into_iter()
.map(|point| crate::SurfacePoint {
body_md: point.body_md,
memory_refs: point
.memory_refs
.into_iter()
.map(|r| crate::MemoryRevisionRef {
memory_id: r.memory_id,
revision: r.revision,
})
.collect(),
})
.collect();
let snapshot = store
.publish_surface_generation(subject_id, &input.generation_id, points)
.map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id,
built_from_store_revision: snapshot.built_from_store_revision,
empty: snapshot.body_md.is_empty(),
})
}
Op::FailSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let revision = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?;
let status = if let Some(job) = job {
store
.validate_job_surface_outcome(
subject_id,
&input.generation_id,
revision,
"failed",
None,
Some(&input.reason_code),
&job.job_id,
&job.attempt_id,
)
.map_err(|e| {
OperationError::Conflict(format!(
"surface failure was not recorded for this attempt: {e}"
))
})?;
"failed_confirmed"
} else {
"failed"
};
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision,
status: status.into(),
})
}
})
}
+343
View File
@@ -0,0 +1,343 @@
use super::*;
use crate::{MemoryDraft, SubjectRole, SubjectSessionAttribution, SubjektivStore};
use memory::extract::CandidateKind;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
fn open(root: &std::path::Path, scope: &str) -> (feature_storage::FeatureStorage, SubjektivStore) {
let manager = feature_storage::FeatureStorage::new(root);
let scope = manager.scope(scope).unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
(manager, store)
}
fn query(cursor: Option<String>) -> Op {
Op::Query(server_api::SubjektivMemoryQueryRequest {
query: None,
kinds: None,
states: None,
limit: Some(1),
cursor,
})
}
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest {
memory_id: id.into(),
revision,
offset,
byte_offset,
limit: Some(1),
evidence_cursor: None,
})
}
#[test]
fn neutral_scope_reopen_and_context_attenuation() {
let temp = tempfile::tempdir().unwrap();
let random_scope = uuid::Uuid::now_v7().to_string();
let (manager, store) = open(temp.path(), &random_scope);
assert_eq!(store.scope_id(), random_scope);
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let consolidation =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(
execute(
&store,
&body,
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None })
)
.is_err()
);
assert!(
execute(
&store,
&consolidation,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {})
)
.is_err()
);
let attribution =
SubjectSessionAttribution::new(&subject.id, "local-host", "worker", "session").unwrap();
assert!(
HostOperationContext::validated_body(&store, &subject.id, Some(attribution.clone()))
.is_err()
);
store
.record_session_attribution(attribution.clone())
.unwrap();
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
let (_, foreign) = open(&temp.path().join("foreign"), "foreign-scope");
assert!(matches!(
execute(&foreign, &body, query(None)),
Err(OperationError::PermissionDenied(_))
));
manager.shutdown().unwrap();
drop(store);
let (_manager, reopened) = open(temp.path(), &random_scope);
assert_eq!(reopened.subject(&subject.id).unwrap().unwrap(), subject);
assert!(reopened.session_attribution("session").unwrap().is_some());
}
#[test]
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let original = "日本語\\\"".repeat(10_000);
let first = store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Lesson,
"first",
&original,
"reason",
"initial",
),
)
.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Decision,
"second",
"second body",
"reason",
"initial",
),
)
.unwrap();
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
panic!()
};
assert!(page.has_more);
assert_eq!(page.items.len(), 1);
let cursor = page.next_cursor.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Constraint,
"third",
"third body",
"reason",
"initial",
),
)
.unwrap();
assert!(matches!(
execute(&store, &context, query(Some(cursor))),
Err(OperationError::Conflict(_))
));
assert!(execute(&store, &context, read(&first.id, None, Some(1), None)).is_err());
let mut offset = None;
let mut byte_offset = None;
let mut reconstructed = String::new();
loop {
let Response::Read(page) = execute(
&store,
&context,
read(&first.id, Some(1), offset, byte_offset),
)
.unwrap() else {
panic!()
};
assert!(
serde_json::to_vec_pretty(&page).unwrap().len()
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
);
reconstructed.push_str(&page.body_md);
if !page.body_truncated {
break;
}
assert!(!page.body_md.is_empty());
offset = page.body_next_offset;
byte_offset = page.body_next_byte_offset;
}
assert_eq!(reconstructed, original);
}
#[test]
fn shared_surface_empty_snapshot_is_ready_not_missing() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let consolidation = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![]),
Some(JobAttemptBinding {
job_id: "job".into(),
attempt_id: "attempt".into(),
}),
)
.unwrap();
let prepared: server_api::SubjektivSurfacePrepareRequest = serde_json::from_str("{}").unwrap();
let Response::SurfacePrepared(prepared) =
execute(&store, &consolidation, Op::PrepareSurface(prepared)).unwrap()
else {
panic!()
};
let Response::SurfacePublished(published) = execute(
&store,
&consolidation,
Op::PublishSurface(server_api::SubjektivSurfacePublishRequest {
generation_id: prepared.generation_id,
points: vec![],
}),
)
.unwrap() else {
panic!()
};
assert!(published.empty);
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let Response::ResidentSummary(resident) = execute(
&store,
&body,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}),
)
.unwrap() else {
panic!()
};
assert_eq!(
resident.availability,
memory::backend::MemoryResidentSummaryAvailability::Ready
);
assert_eq!(resident.content, None);
}
#[test]
fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "random-local-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let attribution =
SubjectSessionAttribution::new(&subject.id, "host", "worker", "session").unwrap();
store
.record_session_attribution(attribution.clone())
.unwrap();
let context =
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
// Host-resolved public Session evidence with no invented Workspace identity.
let input = server_api::SubjektivMemoryStageExplicitRequest {
receipt_id: "receipt-1".into(),
session_id: "session".into(),
kind: CandidateKind::Lesson,
claim: "Preserve correction history".into(),
why_useful: "Allows auditing".into(),
staleness: None,
proposal: None,
evidence: vec![memory::extract::StagingEvidence {
id: "e1".into(),
entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"),
origin: None,
excerpt: Some("Use immutable revisions".into()),
summary: None,
}],
source_refs: vec![memory::schema::SourceEvidenceRef {
session_id: Some("session".into()),
segment_id: Some("segment".into()),
entry_range: Some([1, 1]),
evidence_id: Some("e1".into()),
..Default::default()
}],
};
let mut foreign_session = input.clone();
foreign_session.session_id = "foreign".into();
assert!(matches!(
execute(&store, &context, Op::StageExplicit(foreign_session)),
Err(OperationError::PermissionDenied(_))
));
execute(&store, &context, Op::StageExplicit(input.clone())).unwrap();
execute(&store, &context, Op::StageExplicit(input)).unwrap();
let Response::ReceiptStatus(receipt) = execute(
&store,
&context,
Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest {
receipt_id: "receipt-1".into(),
}),
)
.unwrap() else {
panic!()
};
assert_eq!(
receipt.status,
server_api::SubjektivMemoryReceiptStatus::Staged
);
let candidate_id = receipt.candidate_id.unwrap();
let no_batch =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(matches!(
execute(
&store,
&no_batch,
Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest {
candidate_id: candidate_id.clone()
})
),
Err(OperationError::PermissionDenied(_))
));
let batch = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![candidate_id.clone()]),
None,
)
.unwrap();
assert!(
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap())
)
.is_err()
);
let decision = server_api::SubjektivMemoryCandidateDecisionRequest {
request_id: "decision-1".into(),
candidate_id,
reason: "adopt lesson".into(),
decision: server_api::SubjektivMemoryCandidateDecision::Apply {
target: server_api::SubjektivMemoryApplyTarget::Create,
memory: server_api::SubjektivMemoryDraft {
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(),
why_useful: "Allows auditing".into(),
staleness: None,
derived_from: vec![],
change_reason: "adopt".into(),
},
},
};
let first = execute(&store, &batch, Op::DecideCandidate(decision.clone())).unwrap();
let retry = execute(&store, &batch, Op::DecideCandidate(decision)).unwrap();
assert_eq!(
serde_json::to_value(first).unwrap(),
serde_json::to_value(retry).unwrap()
);
assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1);
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap()),
)
.unwrap();
}
+153
View File
@@ -0,0 +1,153 @@
//! Common consolidation Job policy and verified result projections.
//!
//! Hosts authenticate/admit Jobs and bind their immutable batch and attempt.
//! These helpers do not dispatch, replay, or grant Job execution authority.
use serde::Deserialize;
use crate::api::{OperationError, OperationResult};
use crate::{SubjektivStore, SurfaceAvailability};
pub const CANDIDATE_BATCH_LIMIT: usize = 100;
pub const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
pub const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
/// Selects an ordered prefix using the T-704 complete-result budget. Reserve
/// 2,048 bytes for the Subject/Host surface envelope, then twice the JSON-escaped
/// ID size plus 128 bytes per candidate for its compact verified disposition.
pub fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> OperationResult<Vec<String>> {
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| OperationError::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
}
/// Empty ready Subjects need no work, even when forced. Missing/stale/failed
/// surfaces bypass candidate thresholds; confirmed Memory is never discarded.
pub fn consolidation_required(
candidate_count: usize,
total_bytes: u64,
force: bool,
surface_availability: SurfaceAvailability,
) -> bool {
let surface_needs_generation = surface_availability != SurfaceAvailability::Ready;
if candidate_count == 0 {
return surface_needs_generation;
}
force
|| surface_needs_generation
|| candidate_count >= CONSOLIDATION_THRESHOLD_FILES
|| total_bytes >= CONSOLIDATION_THRESHOLD_BYTES
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
/// Verifies the exact Subject/batch, every immutable disposition, and the actual
/// terminal surface outcome begun by this Job attempt. Model claims alone never
/// establish completion. Same admission semantics as the Backend T-704 adapter.
pub fn validate_result(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
job_id: &str,
attempt_id: &str,
value: &serde_json::Value,
) -> OperationResult<()> {
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| OperationError::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != subject_id || result.candidate_ids != batch {
return Err(OperationError::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
for candidate_id in batch {
if store
.staging_resolution(subject_id, candidate_id)
.map_err(|e| OperationError::Store(e.to_string()))?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
job_id,
attempt_id,
)
.map_err(|e| {
OperationError::InvalidInput(format!("consolidation surface result mismatch: {e}"))
})
}
/// Enriches a previously verified result with compact durable dispositions. Full
/// reasons, affected revisions and provenance remain in the receipt store. This
/// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
value: &serde_json::Value,
) -> OperationResult<serde_json::Value> {
let mut dispositions = Vec::new();
for id in batch {
let resolution = store
.staging_resolution(subject_id, id)
.map_err(|e| OperationError::Store(e.to_string()))?
.ok_or_else(|| {
OperationError::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
dispositions.push(serde_json::json!({"candidate_id": id, "action": resolution.action}));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| {
OperationError::InvalidInput("consolidation result must be an object".into())
})?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
}
#[cfg(test)]
mod tests;
+183
View File
@@ -0,0 +1,183 @@
use super::*;
use crate::{
CandidateDecision, CandidateDecisionRequest, StagingResolutionAction, SubjectRole,
SubjectStagingRecord,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
use memory::schema::SourceRef;
#[test]
fn escaped_ids_use_exact_t704_complete_result_budget() {
const MAX_RESULT_BYTES: u32 = 16 * 1024;
let ids: Vec<_> = (0..100)
.map(|i| format!("{i:03}{}", "\\\"".repeat(126)))
.collect();
let batch = bounded_candidate_batch(ids.clone(), MAX_RESULT_BYTES).unwrap();
assert!(!batch.is_empty());
assert!(batch.len() < ids.len());
assert_eq!(batch, ids[..batch.len()]);
let dispositions: Vec<_> = batch
.iter()
.map(|id| serde_json::json!({"candidate_id": id, "action": "already_covered"}))
.collect();
let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
});
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!(
bounded_candidate_batch(["one".into()], 2048)
.unwrap()
.is_empty()
);
assert_eq!(
bounded_candidate_batch((0..200).map(|i| i.to_string()), u32::MAX)
.unwrap()
.len(),
CANDIDATE_BATCH_LIMIT
);
}
#[test]
fn trigger_policy_preserves_empty_ready_and_surface_retry_rules() {
for availability in [
SurfaceAvailability::Ungenerated,
SurfaceAvailability::Stale,
SurfaceAvailability::Failed,
] {
assert!(consolidation_required(0, 0, false, availability));
assert!(consolidation_required(1, 1, false, availability));
}
assert!(!consolidation_required(
0,
0,
true,
SurfaceAvailability::Ready
));
assert!(!consolidation_required(
4,
49_999,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
5,
0,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
50_000,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
1,
true,
SurfaceAvailability::Ready
));
}
#[test]
fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let temp = tempfile::tempdir().unwrap();
let manager = feature_storage::FeatureStorage::new(temp.path());
let scope = manager.scope("local-random-scope").unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let candidate = SubjectStagingRecord::attach(
&subject.id,
StagingRecord::from_candidate(
"candidate",
"dedup",
SourceRef {
segment_id: "segment".into(),
range: [1, 1],
},
ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: "claim".into(),
why_useful: "reason".into(),
staleness: None,
evidence_ids: vec![],
},
vec![],
vec![],
),
);
store.stage_candidate(candidate).unwrap();
let batch = vec!["candidate".to_string()];
let generation = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let snapshot = store
.publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err()
.to_string()
.contains("unresolved")
);
assert!(with_dispositions(&store, &subject.id, &batch, &ready).is_err());
store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision".into(),
candidate_id: "candidate".into(),
reason: "invalid".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::Invalid,
affected_memory: vec![],
},
},
)
.unwrap();
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).unwrap();
let enriched = with_dispositions(&store, &subject.id, &batch, &ready).unwrap();
assert_eq!(
enriched["candidate_dispositions"],
serde_json::json!([{"candidate_id": "candidate", "action": "invalid"}])
);
assert_eq!(
enriched,
with_dispositions(&store, &subject.id, &batch, &ready).unwrap()
);
assert!(validate_result(&store, &subject.id, &batch, "job", "other-attempt", &ready).is_err());
assert!(validate_result(&store, &subject.id, &[], "job", "attempt", &ready).is_err());
// A current ready snapshot survives a redundant editor failure. Advance
// Memory first so this attempt genuinely needs a new surface and records failure.
store
.create_memory(
&subject.id,
crate::MemoryDraft::active(
CandidateKind::Lesson,
"new lesson",
"new body",
"reason",
"change",
),
)
.unwrap();
let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let revision = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed;
false_reason["surface"]["reason_code"] = serde_json::json!("invented");
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &false_reason).is_err());
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -957,6 +957,7 @@ mod tests {
language: "English".into(),
}),
subjektiv_attached: false,
backend_job: None,
})
.unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap();
+9 -8
View File
@@ -457,20 +457,21 @@ pub(crate) async fn run_standalone(
state_dir: PathBuf,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_name = worker_name.unwrap_or_else(|| "local".to_string());
let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| {
manifest::ProfileSelector::parse_cli(&profile)
});
let history_root = workspace_root.clone();
let launch = StandaloneLaunchConfig {
state_dir,
cwd: workspace_root,
profile,
worker_name: worker_name.clone(),
}
.resolve()
.map_err(|error| {
let launch =
StandaloneLaunchConfig::new(workspace_root, state_dir, profile, worker_name.clone());
let launch = if let Some(subject_id) = subject_id {
launch.with_subject(subject_id)
} else {
launch
};
let launch = launch.resolve().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Standalone launch configuration failed: {error}"),
+4 -1
View File
@@ -65,6 +65,7 @@ pub enum LaunchMode {
Spawn {
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
},
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root.
@@ -202,7 +203,8 @@ async fn launch_mode(
LaunchMode::Spawn {
worker_name,
profile,
} => match standalone_spawn::select(&workspace_root, worker_name, profile) {
subject_id,
} => match standalone_spawn::select(&workspace_root, worker_name, profile, subject_id) {
Ok(Some(selection)) => match target.spawn_worker() {
Ok(spawn) => {
console::run_standalone(
@@ -210,6 +212,7 @@ async fn launch_mode(
spawn.state_dir,
Some(selection.worker_name),
Some(selection.profile),
selection.subject_id,
)
.await
}
+134 -10
View File
@@ -12,13 +12,14 @@ use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6;
const VIEWPORT_HEIGHT: u16 = 7;
const FALLBACK_WORKER_NAME: &str = "worker";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StandaloneSpawnSelection {
pub worker_name: String,
pub profile: String,
pub subject_id: Option<String>,
}
#[derive(Debug, Error)]
@@ -55,6 +56,9 @@ enum SpawnAction {
struct SpawnForm {
worker_name: String,
cursor: usize,
subject_id: String,
subject_cursor: usize,
editing_subject: bool,
profile_choices: Vec<ProfileChoice>,
selected_profile: usize,
status: Option<(String, StatusKind)>,
@@ -75,12 +79,26 @@ impl SpawnForm {
Self {
worker_name,
cursor,
subject_id: String::new(),
subject_cursor: 0,
editing_subject: false,
profile_choices,
selected_profile,
status: None,
}
}
fn with_subject(mut self, subject_id: Option<String>) -> Self {
self.subject_id = subject_id.unwrap_or_default();
self.subject_cursor = self.subject_id.chars().count();
self
}
fn subject_selection(&self) -> Option<String> {
let id = self.subject_id.trim();
(!id.is_empty()).then(|| id.to_owned())
}
fn selected_profile(&self) -> &ProfileChoice {
&self.profile_choices[self.selected_profile]
}
@@ -98,6 +116,47 @@ impl SpawnForm {
}
self.status = None;
if key.code == KeyCode::F(2) {
self.editing_subject = !self.editing_subject;
return SpawnAction::None;
}
if self.editing_subject {
match key.code {
KeyCode::Left => self.subject_cursor = self.subject_cursor.saturating_sub(1),
KeyCode::Right => {
self.subject_cursor =
(self.subject_cursor + 1).min(self.subject_id.chars().count())
}
KeyCode::Home => self.subject_cursor = 0,
KeyCode::End => self.subject_cursor = self.subject_id.chars().count(),
KeyCode::Backspace => {
if self.subject_cursor > 0 {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor - 1));
self.subject_cursor -= 1;
}
}
KeyCode::Delete => {
if self.subject_cursor < self.subject_id.chars().count() {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor));
}
}
KeyCode::Char(ch) if is_safe_worker_char(ch) => {
self.subject_id
.insert(byte_index(&self.subject_id, self.subject_cursor), ch);
self.subject_cursor += 1;
}
KeyCode::Char(_) => {}
// Submit/cancel and profile cycling remain available while editing the Subject.
_ => return self.apply_form_key(key),
}
return SpawnAction::None;
}
self.apply_form_key(key)
}
fn apply_form_key(&mut self, key: KeyEvent) -> SpawnAction {
match key.code {
KeyCode::Esc => SpawnAction::Cancel,
KeyCode::Enter => {
@@ -167,12 +226,14 @@ pub(crate) fn select(
workspace_root: &Path,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
let default_worker_name = default_worker_name(workspace_root);
if let Some(profile) = profile {
return Ok(Some(StandaloneSpawnSelection {
worker_name: worker_name.unwrap_or(default_worker_name),
profile,
subject_id,
}));
}
@@ -185,7 +246,7 @@ pub(crate) fn select(
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
SpawnForm::new(worker_name, default_worker_name, choices).with_subject(subject_id),
)
})
}
@@ -214,6 +275,7 @@ fn run_picker(
let selection = StandaloneSpawnSelection {
worker_name: form.worker_name.trim().to_owned(),
profile: form.selected_profile().selector.clone(),
subject_id: form.subject_selection(),
};
form.status = Some(("starting worker...".to_owned(), StatusKind::Progress));
terminal.draw(|frame| draw_form(frame, &form))?;
@@ -261,6 +323,7 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(frame.area());
@@ -306,12 +369,32 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("subject: ", Style::default().fg(Color::DarkGray)),
Span::styled(
if form.subject_id.is_empty() {
"(none)"
} else {
&form.subject_id
},
Style::default().fg(Color::Cyan),
),
Span::styled(
" (F2 edit name/Subject ID)",
Style::default().fg(Color::DarkGray),
),
])),
chunks[3],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" enter spawn · left/right edit · esc cancel",
Style::default().fg(Color::DarkGray),
))),
chunks[3],
chunks[4],
);
let (message, color) = form
@@ -331,16 +414,20 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Span::raw(" "),
Span::styled(message, Style::default().fg(color)),
])),
chunks[4],
chunks[5],
);
let prefix_width = " name: ".chars().count() as u16;
let x = chunks[1]
let (row, prefix, cursor) = if form.editing_subject {
(3, " subject: ", form.subject_cursor)
} else {
(1, " name: ", form.cursor)
};
let x = chunks[row]
.x
.saturating_add(prefix_width)
.saturating_add(form.cursor as u16)
.min(chunks[1].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[1].y));
.saturating_add(prefix.chars().count() as u16)
.saturating_add(cursor as u16)
.min(chunks[row].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[row].y));
}
fn default_worker_name(workspace_root: &Path) -> String {
@@ -394,9 +481,43 @@ mod tests {
fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi");
assert_eq!(form.subject_selection(), None);
assert_eq!(form.selected_profile().selector, "builtin:standalone");
}
#[test]
fn optional_subject_field_edits_independently_and_can_be_cleared() {
let mut form = SpawnForm::new(None, "worker".to_owned(), choices());
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
for ch in "subject-1".chars() {
form.apply_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(form.subject_selection().as_deref(), Some("subject-1"));
assert_eq!(form.worker_name, "worker");
form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder");
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
SpawnAction::Submit
);
for _ in 0..9 {
form.apply_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
assert_eq!(form.subject_selection(), None);
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(form.worker_name, "worker2");
assert_eq!(form.subject_selection(), None);
}
#[test]
fn explicit_subject_prefills_the_optional_field() {
let form = SpawnForm::new(None, "worker".to_owned(), choices())
.with_subject(Some("subject-id".to_owned()));
assert_eq!(form.subject_selection().as_deref(), Some("subject-id"));
assert_eq!(form.subject_cursor, 10);
}
#[test]
fn tab_and_arrows_cycle_profiles() {
let mut form = SpawnForm::new(None, "yoi".to_owned(), choices());
@@ -453,6 +574,7 @@ mod tests {
assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi"));
assert!(rendered.contains("subject: (none)"));
assert!(rendered.contains("profile: builtin:standalone (default) — Standalone"));
assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
}
@@ -504,10 +626,12 @@ mod tests {
Path::new("/home/hare/Project/yoi"),
None,
Some("builtin:coder".to_owned()),
Some("subject-id".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(selection.worker_name, "yoi");
assert_eq!(selection.profile, "builtin:coder");
assert_eq!(selection.subject_id.as_deref(), Some("subject-id"));
}
}
+434 -14
View File
@@ -125,6 +125,51 @@ pub struct ProfileSourceArchiveInput {
impl ProfileSourceArchive {
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
Self::build_with_source_format(input, "decodal", "text/x-decodal")
}
/// Archive an already evaluated Profile value without Decodal evaluation or conversion.
pub fn build_evaluated_profile(
id: String,
selector: String,
value: serde_json::Value,
) -> Result<Self, ProfileArchiveError> {
Self::build_evaluated_profile_with_builtin_sources(id, selector, value, BTreeMap::new())
}
/// Seal the observed builtin import closure as provenance alongside the evaluated JSON.
/// Builtin sources are verified, but never evaluated to resolve this entrypoint.
pub fn build_evaluated_profile_with_builtin_sources(
id: String,
selector: String,
value: serde_json::Value,
sources: BTreeMap<String, String>,
) -> Result<Self, ProfileArchiveError> {
for key in sources.keys() {
validate_builtin_source_key(key)?;
}
let path = "profiles/evaluated.json".to_string();
let source = serde_json::to_string(&value)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
let mut sources = sources;
sources.insert(path.clone(), source);
Self::build_with_source_format(
ProfileSourceArchiveInput {
id,
entrypoints: BTreeMap::from([(selector, path)]),
imports: BTreeMap::new(),
sources,
},
"json",
"application/json",
)
}
fn build_with_source_format(
input: ProfileSourceArchiveInput,
kind: &str,
content_type: &str,
) -> Result<Self, ProfileArchiveError> {
if input.sources.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
}
@@ -142,11 +187,17 @@ impl ProfileSourceArchive {
if total_source_bytes > MAX_TOTAL_BYTES {
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
}
let (kind, content_type) = if path.starts_with("$builtin/") {
validate_builtin_source_key(path)?;
("decodal", "text/x-decodal")
} else {
(kind, content_type)
};
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
source_key: path.clone(),
kind: "decodal".to_string(),
content_type: default_decodal_content_type(),
kind: kind.to_string(),
content_type: content_type.to_string(),
digest: sha256_hex(source.as_bytes()),
size_bytes: size,
});
@@ -281,14 +332,35 @@ impl VerifiedProfileSourceArchive {
let mut manifest_paths = BTreeSet::new();
for source in &manifest.sources {
validate_archive_path(&source.path)?;
if !source.source_key.is_empty() && source.source_key != source.path {
if source.path.starts_with("$builtin/") {
validate_builtin_source_key(&source.path)?;
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
}
if (source.path.starts_with("$builtin/") || !source.source_key.is_empty())
&& source.source_key != source.path
{
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: source.path.clone(),
expected: source.path.clone(),
actual: source.source_key.clone(),
});
}
if source.content_type != default_decodal_content_type() {
let expected_content_type = match source.kind.as_str() {
"decodal" => "text/x-decodal",
"json" => "application/json",
_ => {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
};
if source.content_type != expected_content_type {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.content_type.clone(),
@@ -297,12 +369,6 @@ impl VerifiedProfileSourceArchive {
if !manifest_paths.insert(source.path.clone()) {
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
}
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
let content = entries
.get(&source.path)
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
@@ -373,30 +439,50 @@ impl VerifiedProfileSourceArchive {
.entrypoints
.get(selector)
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
let source = self
.sources
.get(path)
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
let metadata = self
.manifest
.sources
.iter()
.find(|source| &source.path == path)
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
let json = match metadata.kind.as_str() {
"json" => serde_json::from_str(source)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
"decodal" => {
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
let module = engine.add_root_source(path, path, source).map_err(|err| {
ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
}
})?;
let value = engine
let value =
engine
.eval_module(module)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let data = engine
let data =
engine
.materialize(&value)
.map_err(|err| ProfileArchiveError::Decodal {
path: path.clone(),
message: format!("{err:?}"),
})?;
let json = decodal_data_to_json(&data);
decodal_data_to_json(&data)
}
_ => {
return Err(ProfileArchiveError::UnsupportedSource {
path: path.clone(),
kind: metadata.kind.clone(),
});
}
};
let resolved = resolve_profile_artifact_value(
json,
ProfileSource::Archive {
@@ -609,6 +695,26 @@ fn append_bytes<W: Write>(
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
}
fn validate_builtin_source_key(key: &str) -> Result<(), ProfileArchiveError> {
validate_archive_path(key)?;
let Some(relative) = key.strip_prefix("$builtin/profiles/") else {
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
};
if !relative.ends_with(".dcdl")
|| relative.split('/').any(|part| {
part.is_empty()
|| part == "."
|| part == ".."
|| !part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
})
{
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
}
Ok(())
}
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
if path.is_empty() || path == MANIFEST_PATH {
return if path == MANIFEST_PATH {
@@ -674,6 +780,320 @@ mod tests {
.unwrap()
}
fn evaluated_profile() -> serde_json::Value {
serde_json::json!({
"slug": "evaluated",
"description": "Evaluated profile",
"scope": "workspace_read",
"worker": { "mode": "wip" },
"feature": {
"task": { "enabled": true },
"memory": { "enabled": true },
"sub_worker": { "enabled": true }
}
})
}
fn evaluated_archive() -> ProfileSourceArchive {
ProfileSourceArchive::build_evaluated_profile(
"evaluated-archive".to_string(),
"project:evaluated".to_string(),
evaluated_profile(),
)
.unwrap()
}
#[test]
fn evaluated_json_preserves_explicit_worker_mode_and_features() {
let archive = evaluated_archive();
let verified = archive.verify().unwrap();
let source = &verified.manifest().sources[0];
assert_eq!(source.kind, "json");
assert_eq!(source.content_type, "application/json");
assert_eq!(verified.manifest().imports.len(), 0);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&verified.sources[&source.path]).unwrap(),
evaluated_profile()
);
let root = tempfile::tempdir().unwrap();
let manifest = verified
.resolve_profile("project:evaluated", root.path(), "evaluated-worker")
.unwrap();
let json = serde_json::to_value(&manifest).unwrap();
assert_eq!(manifest.worker.name, "evaluated-worker");
assert_eq!(json["worker"]["mode"], "wip");
assert_eq!(json["feature"]["task"]["enabled"], true);
assert_eq!(json["feature"]["memory"]["profile"]["enabled"], true);
assert_eq!(json["feature"]["sub_worker"]["enabled"], true);
}
// Recompute the outer digest so verification reaches the source-level checks.
fn repack_archive(
archive: &mut ProfileSourceArchive,
manifest: &ProfileSourceArchiveManifest,
sources: &BTreeMap<String, String>,
) {
let mut content = Vec::new();
{
let mut builder = Builder::new(&mut content);
append_bytes(
&mut builder,
MANIFEST_PATH,
&serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
for (path, source) in sources {
append_bytes(&mut builder, path, source.as_bytes()).unwrap();
}
builder.finish().unwrap();
}
archive.reference.digest = sha256_hex(&content);
archive.reference.size_bytes = content.len() as u64;
archive.content = content;
}
fn builtin_provenance_archive() -> ProfileSourceArchive {
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"builtin-provenance".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([
(
"$builtin/profiles/companion.dcdl".into(),
"import \"./base.dcdl\"".into(),
),
// Intentionally invalid Decodal: provenance must not be reevaluated.
(
"$builtin/profiles/base.dcdl".into(),
"not valid Decodal !".into(),
),
]),
)
.unwrap()
}
#[test]
fn evaluated_json_seals_builtin_closure_without_runtime_evaluation() {
let archive = builtin_provenance_archive();
let verified = archive.verify().unwrap();
assert_eq!(
archive,
builtin_provenance_archive(),
"archive must be deterministic"
);
assert_eq!(archive.reference.source_graph.source_count, 3);
assert!(verified.manifest.imports.is_empty());
assert_eq!(
verified.manifest.entrypoints["project:evaluated"],
"profiles/evaluated.json"
);
for source in &verified.manifest.sources {
assert_eq!(source.source_key, source.path);
assert_eq!(
source.digest,
sha256_hex(verified.sources[&source.path].as_bytes())
);
if source.path.starts_with("$builtin/") {
assert_eq!(source.kind, "decodal");
assert_eq!(source.content_type, "text/x-decodal");
} else {
assert_eq!(source.kind, "json");
}
}
let root = tempfile::tempdir().unwrap();
let resolved = verified
.resolve_profile("project:evaluated", root.path(), "sealed-worker")
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(matches!(
verified.resolve_profile("builtin:companion", root.path(), "sealed-worker"),
Err(ProfileArchiveError::MissingEntrypoint(_))
));
}
#[test]
fn evaluated_json_rejects_missing_or_corrupt_builtin_dependency() {
for missing in [true, false] {
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
let path = "$builtin/profiles/base.dcdl";
if missing {
verified.sources.remove(path);
} else {
verified
.sources
.insert(path.into(), "corrupt dependency".into());
}
repack_archive(&mut archive, &verified.manifest, &verified.sources);
match archive.verify().unwrap_err() {
ProfileArchiveError::MissingSource(actual) if missing => assert_eq!(actual, path),
ProfileArchiveError::SourceDigestMismatch { path: actual, .. } if !missing => {
assert_eq!(actual, path)
}
other => panic!("unexpected error: {other}"),
}
}
}
#[test]
fn evaluated_json_builtin_provenance_rejects_unsafe_keys_and_reuses_limits() {
for key in [
"profiles/companion.dcdl",
"builtin:profiles/companion.dcdl",
"$builtin/prompts/companion.dcdl",
"$builtin/profiles/../escape.dcdl",
"$builtin/profiles/./base.dcdl",
"$builtin/profiles//base.dcdl",
"$builtin/profiles/base\\other.dcdl",
"$builtin/profiles/base\0.dcdl",
"/$builtin/profiles/base.dcdl",
"$builtin/profiles/base.json",
"profiles/evaluated.json",
] {
assert!(
matches!(
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"unsafe".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([(key.into(), "{}".into())]),
),
Err(ProfileArchiveError::UnsafePath(_))
),
"{key:?}"
);
}
assert!(matches!(
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"oversized".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([(
"$builtin/profiles/base.dcdl".into(),
"x".repeat(MAX_SOURCE_BYTES as usize + 1)
)]),
),
Err(ProfileArchiveError::LimitExceeded("source bytes"))
));
}
#[test]
fn evaluated_json_builtin_provenance_requires_canonical_key_and_source_format() {
for key in ["", "profiles/base.dcdl", "$builtin/profiles/other.dcdl"] {
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].source_key = key.into();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ImportMapMismatch { .. })
));
}
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].kind = "json".into();
verified.manifest.sources[0].content_type = "application/json".into();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::UnsupportedSource { .. })
));
}
#[test]
fn evaluated_json_reuses_source_size_limit() {
assert!(matches!(
ProfileSourceArchive::build_evaluated_profile(
"oversized".to_string(),
"default".to_string(),
serde_json::Value::String("x".repeat(MAX_SOURCE_BYTES as usize)),
),
Err(ProfileArchiveError::LimitExceeded("source bytes"))
));
}
#[test]
fn evaluated_json_rejects_archive_and_source_tampering() {
let mut archive = evaluated_archive();
archive.content[0] ^= 1;
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ArchiveDigestMismatch { .. })
));
let mut archive = evaluated_archive();
let mut verified = archive.verify().unwrap();
let path = verified.manifest.sources[0].path.clone();
verified
.sources
.get_mut(&path)
.unwrap()
.replace_range(0..1, "[");
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::SourceDigestMismatch { path: actual, .. }) if actual == path
));
}
#[test]
fn evaluated_json_requires_matching_source_kind_and_content_type() {
for (kind, content_type) in [
("json", "text/x-decodal"),
("decodal", "application/json"),
("unknown", "application/json"),
] {
let mut archive = evaluated_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].kind = kind.to_string();
verified.manifest.sources[0].content_type = content_type.to_string();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::UnsupportedSource { .. })
));
}
}
#[test]
fn evaluated_json_does_not_fallback_to_filesystem_or_default() {
let root = tempfile::tempdir().unwrap();
let profiles = root.path().join("profiles");
std::fs::create_dir(&profiles).unwrap();
let local_profile = profiles.join("evaluated.json");
std::fs::write(
&local_profile,
serde_json::to_vec(&evaluated_profile()).unwrap(),
)
.unwrap();
let verified = evaluated_archive().verify().unwrap();
for selector in [
"default",
"profiles/evaluated.json",
local_profile.to_str().unwrap(),
] {
assert!(matches!(
verified.resolve_profile(selector, root.path(), "evaluated-worker"),
Err(ProfileArchiveError::MissingEntrypoint(actual)) if actual == selector
));
}
// A valid local file must not rescue an invalid archived Profile value either.
let invalid = ProfileSourceArchive::build_evaluated_profile(
"invalid".to_string(),
"project:evaluated".to_string(),
serde_json::json!({ "not_a_profile_field": true }),
)
.unwrap()
.verify()
.unwrap();
assert!(matches!(
invalid.resolve_profile("project:evaluated", root.path(), "evaluated-worker"),
Err(ProfileArchiveError::Profile { .. })
));
}
#[test]
fn build_and_verify_archive() {
let archive = sample_archive();
+1
View File
@@ -16,6 +16,7 @@ agen = { workspace = true, features = ["codex"] }
session-store = { workspace = true }
secrets = { workspace = true }
manifest = { workspace = true }
job = { workspace = true }
mcp = { workspace = true }
protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true }
+48
View File
@@ -52,6 +52,7 @@ pub struct WorkerBootstrap<St> {
transport: WorkerControllerTransport,
model_client: Option<Box<dyn LlmClient>>,
workdir_session: Option<WorkdirSessionHandle>,
subjektiv_host: Option<std::sync::Arc<dyn crate::subjektiv::SubjektivHost>>,
}
/// A constructed Worker whose host-owned live bindings can still be installed
@@ -106,6 +107,7 @@ where
transport,
model_client: None,
workdir_session: None,
subjektiv_host: None,
}
}
@@ -127,6 +129,16 @@ where
self
}
/// Bind an explicit, process-owned subject capability. Profile policy alone
/// is inert; the connection is never persisted into the Worker manifest.
pub fn with_subjektiv_host(
mut self,
host: std::sync::Arc<dyn crate::subjektiv::SubjektivHost>,
) -> Self {
self.subjektiv_host = Some(host);
self
}
/// Construct the Worker without exposing a controller handle. Runtime hosts
/// use this seam to bind Workdir, observation, Flow, and other live services
/// before [`PreparedWorker::start`] performs Feature installation.
@@ -147,6 +159,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::NewSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
@@ -170,6 +200,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::RestoredSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
+17 -16
View File
@@ -1377,6 +1377,7 @@ where
None => durable_parent_notifications,
};
let backend_job = worker.backend_job().cloned();
let subjektiv_host = worker.subjektiv_host_connection()?;
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone();
@@ -1443,8 +1444,8 @@ where
workspace_client.clone(),
);
if binding.subjektiv_consolidation {
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_host(
subjektiv_host.clone().ok_or_else(|| std::io::Error::other("Backend Job requires subjektiv Host capability"))?, spawner_manifest.clone(), worker.llm_client_handle(),
prompts.clone(), spawner_workspace_context.clone(),
)?;
result_feature = result_feature.with_surface(surface);
@@ -1453,7 +1454,9 @@ where
}
let memory_profile = &worker.manifest().feature.memory.profile;
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
if subjektiv_host.is_some() {
validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?;
}
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
@@ -1476,7 +1479,7 @@ where
let subjektiv_consolidation_plan =
crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
worker.prompts().load_full(),
)?;
if let Some(plan) = subjektiv_consolidation_plan {
@@ -1485,22 +1488,20 @@ where
}
feature_registry.add_module(plan.module);
}
let ordinary_subjektiv_features_enabled =
crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
let ordinary_subjektiv_features_enabled = subjektiv_host.is_some()
&& crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
if let Some(resident_summary_source) =
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
worker.prompts(),
)?
{
let workspace_id = worker
.workspace_client()
.workspace_id()
.expect("validated subject resident source has Workspace identity")
.to_string();
let workspace_id = subjektiv_host
.as_ref()
.and_then(|host| host.host.workspace_id());
if let Some(refresh_feature) =
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host(
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_connection(
lifecycle_enabled,
Arc::clone(&resident_summary_source),
worker.prompts(),
@@ -1516,7 +1517,7 @@ where
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
)?
{
feature_registry.add_module(subjektiv_memory);
@@ -1525,7 +1526,7 @@ where
&& let Some(subjektiv_sessions) =
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.workspace_client_handle(),
subjektiv_host.clone(),
)?
{
feature_registry.add_module(subjektiv_sessions);
@@ -1548,7 +1549,7 @@ where
}
if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
lifecycle_enabled,
worker.workspace_client_handle(),
subjektiv_host.clone(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
@@ -1563,7 +1564,7 @@ where
worker.manifest().feature.subjektiv.clone(),
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
worker.workspace_client_handle(),
subjektiv_host.clone(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
+102 -160
View File
@@ -4,8 +4,10 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -158,6 +160,7 @@ impl dyn WorkspaceClient + '_ {
}
}
#[cfg(test)]
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
@@ -327,9 +330,7 @@ pub fn workspace_http_memory_tools(client: Arc<dyn WorkspaceClient>) -> Vec<Tool
]
}
pub fn workspace_http_subjektiv_consolidation_tools(
client: Arc<dyn WorkspaceClient>,
) -> Vec<ToolDefinition> {
fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefinition> {
use SubjectConsolidationOperation as Operation;
[
(
@@ -371,14 +372,14 @@ pub fn workspace_http_subjektiv_consolidation_tools(
]
.into_iter()
.map(|(name, description, schema, operation)| {
let client = Arc::clone(&client);
let host = host.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(schema.clone()),
Arc::new(SubjectConsolidationTool {
client: Arc::clone(&client),
host: host.clone(),
operation,
}) as Arc<dyn Tool>,
)
@@ -398,7 +399,7 @@ enum SubjectConsolidationOperation {
}
struct SubjectConsolidationTool {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
operation: SubjectConsolidationOperation,
}
@@ -434,59 +435,17 @@ impl Tool for SubjectConsolidationTool {
)?)
}
};
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed(
"subjektiv consolidation requires Workspace authority".into(),
)
})?;
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let parsed =
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| (diagnostic.code.clone(), error.message.clone()))
}) {
let response = match self.host.memory(operation) {
Ok(response) => response,
Err(SubjektivHostError::Conflict { code, message }) => {
return Ok(ToolOutput {
summary: format!("Candidate decision requires reread: {code}."),
content: Some(
serde_json::json!({
"status": "error",
"error": { "code": code, "message": message }
})
.to_string(),
),
content: Some(serde_json::json!({"status":"error", "error":{"code":code,"message":message}}).to_string()),
attachments: Vec::new(),
});
}
let detail = parsed.map(|error| error.message).unwrap_or(response.body);
return Err(if matches!(response.status, 400 | 404 | 409 | 422) {
ToolError::InvalidArgument(detail)
} else {
ToolError::ExecutionFailed(detail)
});
}
let response: server_api::SubjektivMemoryBackendResponse =
serde_json::from_str(&response.body)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
Err(error) => return Err(error.into()),
};
let summary = match &response {
server_api::SubjektivMemoryBackendResponse::Query(value) => {
format!("Found {} subject Memory item(s).", value.items.len())
@@ -686,7 +645,7 @@ struct SubjectBehaviorRefreshState {
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
@@ -730,16 +689,12 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
match self.host.memory(
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
other => Err(SubjektivHostError::Unavailable(format!(
"unexpected subject resident context response: {other:?}"
))),
}
@@ -862,10 +817,11 @@ const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-befor
pub(crate) struct SubjektivResidentRestoreRefreshFeature {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
impl SubjektivResidentRestoreRefreshFeature {
#[cfg(test)]
pub(crate) fn for_host(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
@@ -875,6 +831,20 @@ impl SubjektivResidentRestoreRefreshFeature {
lifecycle_enabled.then(|| Self::new(source, prompts, workspace_id))
}
pub(crate) fn for_connection(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: Option<String>,
) -> Option<Self> {
lifecycle_enabled.then_some(Self {
source,
prompts,
workspace_id,
})
}
#[cfg(test)]
pub(crate) fn new(
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -883,7 +853,7 @@ impl SubjektivResidentRestoreRefreshFeature {
Self {
source,
prompts,
workspace_id: workspace_id.into(),
workspace_id: Some(workspace_id.into()),
}
}
}
@@ -981,7 +951,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
#[async_trait]
@@ -1000,7 +970,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
context.system_items().append_resident_summary_refresh(
body,
session_store::PromptRenderProvenance {
workspace_id: Some(self.workspace_id.clone()),
workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
@@ -1114,51 +1084,25 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
}
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
manifest.feature.subjektiv.execution_enabled()
manifest.feature.subjektiv.profile.enabled
&& !manifest.feature.subjektiv.profile.consolidation_tools
}
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
if !ordinary_subjektiv_features_enabled(manifest) || host.is_none() {
return Ok(None);
}
manifest
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
)
})?;
let settings = manifest
.feature
.subjektiv
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv Workspace settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
));
}
.map_err(std::io::Error::other)?;
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
host: host.expect("checked explicit connection"),
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
@@ -1172,57 +1116,51 @@ pub(crate) struct SubjektivConsolidationFeatureInstallPlan {
impl SubjektivConsolidationFeatureInstallPlan {
pub(crate) fn prepare(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
) -> std::io::Result<Option<Self>> {
if !manifest.feature.subjektiv.profile.consolidation_tools {
let config = &manifest.feature.subjektiv;
if !config.profile.enabled || !config.profile.consolidation_tools || host.is_none() {
return Ok(None);
}
let config = &manifest.feature.subjektiv;
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !config.execution_enabled() || config.profile.extraction.enabled {
config.validate_execution().map_err(std::io::Error::other)?;
let host = host.expect("checked explicit connection");
if config.profile.extraction.enabled || !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation profile requires trusted subject settings without automatic extraction",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation tools require Backend Workspace API authority",
)
})?;
let settings = config
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
"subjektiv consolidation requires explicit Host grant without automatic extraction",
));
}
let system_prompt_override = prompts
.subjektiv_memory_consolidation_system(&settings.language)
.subjektiv_memory_consolidation_system(&host.host.settings().language)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(Some(Self {
module: SubjektivConsolidationToolsFeature {
tools: workspace_http_subjektiv_consolidation_tools(client),
},
module: SubjektivConsolidationToolsFeature::new(host)?,
system_prompt_override,
}))
}
}
#[derive(Clone)]
pub(crate) struct SubjektivConsolidationToolsFeature {
pub struct SubjektivConsolidationToolsFeature {
tools: Vec<ToolDefinition>,
}
impl SubjektivConsolidationToolsFeature {
/// Reusable installation for a generic Job with an explicit attenuated Host.
pub fn new(host: SubjektivHostConnection) -> std::io::Result<Self> {
if !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Host did not grant consolidation",
));
}
Ok(Self {
tools: subjektiv_consolidation_tools(host),
})
}
}
impl FeatureModule for SubjektivConsolidationToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(
@@ -1293,6 +1231,29 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn backend_resident_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
let host = crate::subjektiv::BackendSubjektivHost::from_resolved_config(
&manifest.feature.subjektiv,
client,
)?;
let host = host
.map(|host| {
SubjektivHostConnection::new(
host,
crate::subjektiv::SubjektivHostContext {
worker_id: manifest.worker.name.clone(),
session_id: "session-1".into(),
},
)
})
.transpose()?;
ordinary_subjektiv_resident_summary_source(manifest, host, prompts)
}
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
@@ -1601,7 +1562,7 @@ mod tests {
let plan = SubjektivConsolidationFeatureInstallPlan::prepare(
&manifest,
test_client(),
Some(crate::subjektiv::test_connection(test_client())),
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)
.unwrap()
@@ -1696,11 +1657,7 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_client.clone(),
test_prompts(),
)
let ready = backend_resident_source(&manifest, ready_client.clone(), test_prompts())
.unwrap()
.unwrap();
let loaded = ready.load().await;
@@ -1722,11 +1679,7 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_empty_client,
test_prompts(),
)
let ready_empty = backend_resident_source(&manifest, ready_empty_client, test_prompts())
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
@@ -1740,7 +1693,7 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Stale,
memory::backend::MemoryResidentSummaryAvailability::Failed,
] {
let source = ordinary_subjektiv_resident_summary_source(
let source = backend_resident_source(
&manifest,
Arc::new(SubjectResidentClient::new(
availability,
@@ -1765,8 +1718,7 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
@@ -1838,8 +1790,7 @@ permission = "write"
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied());
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
@@ -1874,7 +1825,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
backend_resident_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1886,28 +1837,20 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(
&policy_only,
unattached_client.clone(),
test_prompts()
)
backend_resident_source(&policy_only, unattached_client.clone(), test_prompts())
.unwrap()
.is_none()
);
assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace");
assert!(
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
.is_err()
);
assert!(backend_resident_source(&foreign, test_client(), test_prompts()).is_err());
}
#[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace");
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
let source = backend_resident_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
@@ -1923,8 +1866,7 @@ permission = "write"
#[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace");
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
let source = backend_resident_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
@@ -2034,7 +1976,7 @@ permission = "write"
(403, "subject_scope_mismatch"),
] {
let tool = SubjectConsolidationTool {
client: repository_error_client(status, code),
host: crate::subjektiv::test_connection(repository_error_client(status, code)),
operation: SubjectConsolidationOperation::DecideCandidate,
};
let output = tool
@@ -2051,7 +1993,7 @@ permission = "write"
#[test]
fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() {
let tools = workspace_http_subjektiv_consolidation_tools(test_client());
let tools = subjektiv_consolidation_tools(crate::subjektiv::test_connection(test_client()));
let names = tool_names(tools.clone());
assert_eq!(
names,
@@ -101,6 +101,7 @@ struct MemoryLifecycleTask {
capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>,
subjektiv_host: Option<crate::subjektiv::SubjektivHostConnection>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -175,6 +176,7 @@ impl MemoryLifecycleFeature {
capture,
extensions,
workspace_client,
subjektiv_host: None,
manifest,
client,
prompts,
@@ -192,32 +194,34 @@ impl SubjektivLifecycleFeature {
config: manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>,
host: Option<crate::subjektiv::SubjektivHostConnection>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
event_tx: Option<broadcast::Sender<Event>>,
) -> std::io::Result<Option<Self>> {
if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled {
if !lifecycle_enabled
|| !config.profile.enabled
|| host.is_none()
|| !config.profile.extraction.enabled
{
return Ok(None);
}
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv extraction requires Backend Workspace API authority",
));
}
Ok(Some(Self {
task: MemoryLifecycleTask {
config: LifecycleConfig::from_subjektiv(config),
target: ExtractionTarget::Subjektiv,
capture,
extensions,
workspace_client,
workspace_client: crate::unavailable_workspace_client(
None,
"subjektiv Host has no legacy Memory authority",
),
subjektiv_host: host,
manifest,
client,
prompts,
@@ -477,10 +481,11 @@ impl MemoryLifecycleTask {
),
ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv(
view.clone(),
Arc::clone(&self.workspace_client),
self.subjektiv_host
.clone()
.expect("subjektiv task has Host connection"),
source,
audit.run_id.to_string(),
capture.session_id.clone(),
),
};
let client = if let Some(model) = self.config.extraction.model.as_ref() {
@@ -499,6 +504,11 @@ impl MemoryLifecycleTask {
.config
.workspace_settings()
.map(|snapshot| snapshot.language)
.or_else(|| {
self.subjektiv_host
.as_ref()
.map(|host| host.host.settings().language)
})
else {
self.record_preparation_failure(
&audit,
@@ -737,14 +747,25 @@ impl MemoryLifecycleTask {
.with_workspace_settings(self.config.workspace_settings.as_ref());
let operation = memory::backend::MemoryConsolidateStagingOperation { force: false };
let request = if subjektiv {
self.workspace_client
.request_subjektiv_memory_staging_consolidation(operation)
.await
let host = self
.subjektiv_host
.as_ref()
.expect("subjektiv task has Host connection");
host.host
.request_consolidation(&host.context, operation)
.map_err(|error| {
super::memory::WorkspaceMemoryBackendError::Backend(error.to_string())
})
} else {
self.workspace_client
.request_memory_staging_consolidation(operation)
.await
};
let audit = if subjektiv {
audit.without_legacy_backend()
} else {
audit
};
match request {
Ok(output) => {
tracing::debug!(
@@ -1319,6 +1340,7 @@ permission = "write"
capture: capture_handle,
extensions,
workspace_client,
subjektiv_host: None,
manifest: test_manifest(),
client,
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1379,7 +1401,7 @@ permission = "write"
manifest::ResolvedSubjektivFeatureConfig::default(),
capture,
extensions,
crate::worker::marker_workspace_client(None, "disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1407,7 +1429,7 @@ permission = "write"
config,
capture,
extensions,
crate::worker::marker_workspace_client(None, "extraction-disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1444,7 +1466,7 @@ permission = "write"
config,
capture,
extensions,
crate::worker::marker_workspace_client(None, "child-disabled"),
None,
test_manifest(),
Box::new(ScriptClient::new(Vec::new())),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -1593,6 +1615,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1647,6 +1672,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1689,6 +1717,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
run_background_task(task).await;
@@ -1806,6 +1837,9 @@ permission = "write"
workspace_client.clone(),
);
task.target = ExtractionTarget::Subjektiv;
task.subjektiv_host = Some(crate::subjektiv::test_connection(
task.workspace_client.clone(),
));
task.config.consolidation_request_enabled = false;
let registry = start_background_task(task);
tokio::time::timeout(Duration::from_secs(5), async {
@@ -41,7 +41,9 @@ pub(crate) struct MemoryStagingOutputState {
#[derive(Clone)]
enum MemoryStagingDestination {
WorkspaceMemory,
Subjektiv { session_id: String },
Subjektiv {
host: crate::subjektiv::SubjektivHostConnection,
},
}
impl MemoryStagingOutputState {
@@ -64,17 +66,19 @@ impl MemoryStagingOutputState {
pub(crate) fn new_subjektiv(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
host: crate::subjektiv::SubjektivHostConnection,
source: SourceRef,
extract_run_id: String,
session_id: String,
) -> Self {
Self {
view: Arc::new(view),
workspace_client,
workspace_client: crate::unavailable_workspace_client(
None,
"subjektiv Host has no legacy Memory authority",
),
source,
extract_run_id,
destination: MemoryStagingDestination::Subjektiv { session_id },
destination: MemoryStagingDestination::Subjektiv { host },
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
@@ -263,35 +267,11 @@ impl Tool for StageMemoryCandidateTool {
}
}
}
MemoryStagingDestination::Subjektiv { session_id } => {
let response = self
.state
.workspace_client
.execute_server_operation(
crate::worker::WorkspaceServerOperation::SubjektivStageCandidate(
server_api::SubjektivStageCandidateRequest {
session_id: session_id.clone(),
operation,
},
),
)
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"write subjektiv staging failed: {error}"
))
})?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"write subjektiv staging returned HTTP {}: {}",
response.status, response.body
)));
}
let output: server_api::SubjektivStageCandidateResponse =
serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"decode subjektiv staging response: {error}"
))
})?;
MemoryStagingDestination::Subjektiv { host } => {
let output = host
.host
.stage_candidate(&host.context, operation)
.map_err(ToolError::from)?;
vec![output.staging_id]
}
};
@@ -607,13 +587,16 @@ mod tests {
let client = Arc::new(RecordingSubjektivClient::default());
let state = MemoryStagingOutputState::new_subjektiv(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
client.clone(),
{
let mut host = crate::subjektiv::test_connection(client.clone());
host.context.session_id = "session-committed".into();
host
},
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
"run-1".to_string(),
"session-committed".to_string(),
);
let tool = StageMemoryCandidateTool {
state: state.clone(),
@@ -14,9 +14,7 @@ use crate::feature::background::{
BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger,
FeatureBackgroundTask,
};
use crate::feature::builtin::memory::{
WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile,
};
use crate::feature::builtin::memory::is_builtin_subjektiv_consolidation_profile;
use crate::feature::builtin::memory_surface_output::{
MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition,
};
@@ -29,7 +27,10 @@ use crate::internal_worker::{
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
run_internal_worker_with_cancel_sender,
};
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient};
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
#[cfg(test)]
use crate::worker::WorkspaceClient;
use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext};
use manifest::WorkerManifest;
const TASK_NAME: &str = "subjektiv-memory-surface";
@@ -39,13 +40,13 @@ const EDITOR_MAX_TURNS: u32 = 3;
const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。";
#[derive(Clone)]
pub(crate) struct SubjektivSurfaceLifecycleFeature {
pub struct SubjektivSurfaceLifecycleFeature {
task: SubjektivSurfaceLifecycleTask,
}
#[derive(Clone)]
struct SubjektivSurfaceLifecycleTask {
workspace_client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -94,18 +95,20 @@ impl LlmClient for InputBudgetLlmClient {
impl SubjektivSurfaceLifecycleFeature {
pub(crate) fn from_manifest(
lifecycle_enabled: bool,
workspace_client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
) -> std::io::Result<Option<Self>> {
let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest);
if !lifecycle_enabled || !dedicated {
if !lifecycle_enabled
|| !is_builtin_subjektiv_consolidation_profile(&manifest)
|| host.is_none()
{
return Ok(None);
}
Self::for_job(
workspace_client,
Self::for_host(
host.expect("checked connection"),
manifest,
client,
prompts,
@@ -114,8 +117,9 @@ impl SubjektivSurfaceLifecycleFeature {
.map(Some)
}
pub(crate) fn for_job(
workspace_client: Arc<dyn WorkspaceClient>,
/// Install the reusable surface Feature for an explicitly granted Host.
pub fn for_host(
host: SubjektivHostConnection,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -125,22 +129,16 @@ impl SubjektivSurfaceLifecycleFeature {
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !manifest.feature.subjektiv.execution_enabled() {
.map_err(std::io::Error::other)?;
if !manifest.feature.subjektiv.profile.enabled || !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv surface generation requires trusted subject attachment",
));
}
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv surface generation requires Backend Workspace API authority",
std::io::ErrorKind::PermissionDenied,
"surface generation requires explicit Host consolidation grant",
));
}
Ok(Self {
task: SubjektivSurfaceLifecycleTask {
workspace_client,
host,
manifest,
client,
prompts,
@@ -150,7 +148,7 @@ impl SubjektivSurfaceLifecycleFeature {
}
/// The Job tool awaits this clean-context lifecycle before submitting its result.
pub(crate) async fn complete_for_job(
pub async fn complete_for_job(
&self,
cancellation: BackgroundTaskCancellation,
reusable_surface: Option<&serde_json::Value>,
@@ -234,7 +232,7 @@ impl SubjektivSurfaceLifecycleTask {
));
}
let generation = self
.workspace_client
.host
.prepare_subjektiv_memory_surface()
.await
.map_err(surface_hook_error)?;
@@ -299,7 +297,7 @@ impl SubjektivSurfaceLifecycleTask {
));
}
match self
.workspace_client
.host
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
generation_id: generation.generation_id.clone(),
points,
@@ -312,9 +310,8 @@ impl SubjektivSurfaceLifecycleTask {
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
}));
}
Err(WorkspaceMemoryBackendError::Http { status, .. })
if status == reqwest::StatusCode::CONFLICT
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
Err(SubjektivHostError::Conflict { .. })
if attempt + 1 < MAX_GENERATION_ATTEMPTS =>
{
continue;
}
@@ -334,18 +331,7 @@ impl SubjektivSurfaceLifecycleTask {
generation: &server_api::SubjektivSurfacePrepareResponse,
cancellation: BackgroundTaskCancellation,
) -> Result<Vec<server_api::SubjektivSurfacePoint>, SurfaceEditorFailure> {
let language = self
.manifest
.feature
.subjektiv
.workspace_settings()
.map(|settings| settings.language)
.ok_or_else(|| {
SurfaceEditorFailure::editor(HookError::new(
HookErrorCategory::Internal,
"surface editor requires bound subjektiv Workspace settings",
))
})?;
let language = self.host.host.settings().language;
let system_prompt = self
.prompts
.load_full()
@@ -424,7 +410,7 @@ impl SubjektivSurfaceLifecycleTask {
job_confirmation_required: bool,
) -> Result<serde_json::Value, HookError> {
let failure = self
.workspace_client
.host
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
generation_id: generation.generation_id.clone(),
reason_code: reason_code.to_string(),
@@ -586,7 +572,7 @@ fn estimated_editor_request_tokens(request: &Request) -> Result<usize, HookError
Ok(request.len().saturating_add(3) / 4)
}
fn surface_hook_error(error: WorkspaceMemoryBackendError) -> HookError {
fn surface_hook_error(error: SubjektivHostError) -> HookError {
HookError::new(HookErrorCategory::Internal, error.to_string())
}
@@ -889,7 +875,7 @@ permission = "write"
calls: Arc<AtomicUsize>,
) -> SubjektivSurfaceLifecycleTask {
SubjektivSurfaceLifecycleTask {
workspace_client,
host: crate::subjektiv::test_connection(workspace_client),
manifest: test_manifest(),
client: Box::new(SurfaceEditorClient { calls }),
prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
@@ -13,7 +13,7 @@ use memory::extract::CandidateKind;
use schemars::JsonSchema;
use serde::Deserialize;
use server_api::{
SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse,
SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse,
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
@@ -33,7 +33,11 @@ use crate::hook::{
PreRequestContext, RunCommitted, RunCommittedContext,
};
use crate::session_capture::{SessionCapture, SessionEntryEvidence};
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
use crate::subjektiv::SubjektivHostConnection;
#[cfg(test)]
use crate::worker::WorkspaceClient;
#[cfg(test)]
use server_api::SubjektivMemoryBackendRequest;
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead";
@@ -58,7 +62,7 @@ pub(crate) struct SubjektivMemoryFeature {
#[derive(Clone)]
struct SubjektivMemoryState {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
capture: CommittedSessionCaptureHandle,
}
@@ -76,22 +80,17 @@ impl SubjektivMemoryFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
) -> std::io::Result<Option<Self>> {
if !config.execution_enabled() {
if !config.profile.enabled || host.is_none() {
return Ok(None);
}
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() || client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv Memory tools require Backend Workspace API authority",
));
}
let host = host.expect("checked explicit connection");
Ok(Some(Self {
state: SubjektivMemoryState { client, capture },
state: SubjektivMemoryState { host, capture },
}))
}
}
@@ -483,48 +482,7 @@ impl SubjektivMemoryState {
&self,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, ToolError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivMemory(
SubjektivMemoryBackendRequest { operation },
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let parsed =
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict" | "stale_cursor"
)
})
.map(|diagnostic| (diagnostic.code.as_str(), error.message.as_str()))
}) {
return Err(ToolError::StructuredConflict {
code: code.to_string(),
message: message.to_string(),
});
}
let detail = parsed
.map(|error| error.message)
.unwrap_or_else(|| response.body.clone());
let message = format!(
"subjektiv Memory backend returned HTTP {}: {detail}",
response.status
);
return if matches!(response.status, 400 | 404 | 409 | 422) {
Err(ToolError::InvalidArgument(message))
} else {
Err(ToolError::ExecutionFailed(message))
};
}
serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!("decode subjektiv response: {error}"))
})
self.host.memory(operation).map_err(Into::into)
}
fn backend_receipt_status(
@@ -628,7 +586,7 @@ struct PendingRetryHook {
#[async_trait]
impl Hook<PreLlmRequest> for PendingRetryHook {
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
replay_pending_committed_memory(&self.state)?;
replay_pending_committed_memory_with_policy(&self.state, true)?;
Ok(HookPreRequestAction::Continue)
}
}
@@ -649,6 +607,12 @@ impl Hook<BeforeSessionRewrite> for PendingRewriteHook {
}
fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), HookError> {
replay_pending_committed_memory_with_policy(state, false)
}
fn replay_pending_committed_memory_with_policy(
state: &SubjektivMemoryState,
allow_pending: bool,
) -> Result<(), HookError> {
let capture = state
.capture
.capture()
@@ -691,9 +655,13 @@ fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), H
vec![call.evidence],
item.proposal,
) {
if !allow_pending
|| !matches!(&error,ToolError::StructuredConflict {code,..} if code=="pending_commit")
{
failures.push(format!("{receipt_id}: {error}"));
}
}
}
if failures.is_empty() {
Ok(())
} else {
@@ -1109,7 +1077,7 @@ mod tests {
});
SubjektivMemoryFeature {
state: SubjektivMemoryState {
client: Arc::new(TestWorkspaceClient),
host: crate::subjektiv::test_connection(Arc::new(TestWorkspaceClient)),
capture,
},
}
@@ -1245,7 +1213,7 @@ mod tests {
"\\\u{0000}".repeat(server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES);
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(response)),
host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient(response))),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
@@ -1265,7 +1233,9 @@ mod tests {
let oversized_tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ReadWorkspaceClient(oversized_response)),
host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient(
oversized_response,
))),
capture: feature().state.capture,
},
operation: ReadOperation::Read,
@@ -1285,7 +1255,9 @@ mod tests {
for code in ["revision_conflict", "stale_cursor"] {
let tool = SubjektivReadTool {
state: SubjektivMemoryState {
client: Arc::new(ConflictWorkspaceClient(code)),
host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient(
code,
))),
capture: feature().state.capture,
},
operation: ReadOperation::Query,
@@ -1386,7 +1358,7 @@ mod tests {
});
PendingCommitHook {
state: SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
},
}
@@ -1439,7 +1411,7 @@ mod tests {
});
let hook = PendingCommitHook {
state: SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
},
};
@@ -1515,7 +1487,7 @@ mod tests {
})
});
let state = SubjektivMemoryState {
client: client.clone(),
host: crate::subjektiv::test_connection(client.clone()),
capture,
};
assert_eq!(
@@ -11,16 +11,20 @@ use async_trait::async_trait;
use schemars::JsonSchema;
use server_api::{
SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivSessionBackendOperation,
SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult,
SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest,
SubjektivSessionReadRequest, SubjektivSessionSearchRequest,
SubjektivSessionBackendResponse, SubjektivSessionBackendResult, SubjektivSessionDiagnosticCode,
SubjektivSessionErrorResponse, SubjektivSessionListRequest, SubjektivSessionReadRequest,
SubjektivSessionSearchRequest,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
use crate::subjektiv::SubjektivHostConnection;
#[cfg(test)]
use crate::worker::WorkspaceClient;
#[cfg(test)]
use server_api::SubjektivSessionBackendRequest;
const LIST_TOOL: &str = "SubjektivSessionList";
const SEARCH_TOOL: &str = "SubjektivSessionSearch";
@@ -37,28 +41,23 @@ pub(crate) struct SubjektivSessionFeature {
#[derive(Clone)]
struct SubjektivSessionState {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
}
impl SubjektivSessionFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
) -> std::io::Result<Option<Self>> {
if !config.execution_enabled() {
if !config.profile.enabled || host.is_none() {
return Ok(None);
}
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() || client.workspace_id().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv Session tools require Backend Workspace API authority",
));
}
let host = host.expect("checked explicit connection");
Ok(Some(Self {
state: SubjektivSessionState { client },
state: SubjektivSessionState { host },
}))
}
}
@@ -154,25 +153,7 @@ impl SubjektivSessionState {
&self,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, ToolError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Ok(response) =
serde_json::from_str::<SubjektivSessionBackendResponse>(&response.body)
{
return Ok(response);
}
let detail = serde_json::from_str::<server_api::RepositoryApiError>(&response.body)
.map(|error| error.message)
.unwrap_or(response.body);
Err(ToolError::ExecutionFailed(format!(
"subjektiv Session backend returned HTTP {}: {detail}",
response.status
)))
self.host.session(operation).map_err(Into::into)
}
}
@@ -293,7 +274,9 @@ mod tests {
}
fn state(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionState {
SubjektivSessionState { client }
SubjektivSessionState {
host: crate::subjektiv::test_connection(client),
}
}
fn feature(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionFeature {
@@ -363,7 +346,9 @@ mod tests {
let config = manifest::ResolvedSubjektivFeatureConfig::default();
let result = SubjektivSessionFeature::from_resolved_config(
&config,
Arc::new(SessionToolClient::new(list_response())),
Some(crate::subjektiv::test_connection(Arc::new(
SessionToolClient::new(list_response()),
))),
)
.unwrap();
assert!(result.is_none());
+3
View File
@@ -205,6 +205,9 @@ where
.copied()
.collect::<Vec<_>>()
.join(", ");
worker
.stop_feature_runtime("internal Worker feature installation failed")
.await;
return Err(InternalWorkerError {
source: WorkerError::FeatureInstall(format!(
"internal Worker feature installation failed: {diagnostics}; missing tools: {missing}"
+374
View File
@@ -0,0 +1,374 @@
//! Host-facing, result-only Jobs executed by the private Internal Worker substrate.
//!
//! The host owns persistence, concurrency, timeout, retries and result acceptance. Normal
//! timeout/shutdown routes through the cancellation sender and awaits `run`, including Worker
//! resource cleanup. An emergency drop/abort does not confirm asynchronous cleanup; the Host
//! must retain its lease and reconcile any unknown outcome before allowing another execution.
//! The result-only Feature owns no background tasks, but an in-flight synchronous Host sink
//! cannot be preempted by dropping its tool future and must itself be bounded.
mod result;
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
use std::borrow::Cow;
use std::path::Path;
use std::sync::Arc;
use agen::llm_client::LlmClient;
use manifest::{ProfileDiscovery, ProfileResolveOptions, ProfileResolver, Scope, WorkerManifest};
use crate::feature::FeatureRegistryBuilder;
use crate::internal_worker::{
InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec,
run_internal_worker_with_cancel_sender,
};
use crate::{
PromptCatalog, PromptCatalogSource, SystemPromptContext, SystemPromptTemplate,
WorkerFilesystemAuthority, WorkerPrompt, WorkerRunResult, WorkerWorkspaceContext,
};
/// Host adapter ceilings, applied even if the selected Profile leaves budgets unbounded.
pub const MAX_JOB_TURNS: u32 = 32;
pub const MAX_JOB_TOKENS: u32 = 8192;
#[derive(Debug, thiserror::Error)]
pub enum JobExecutionError {
#[error(transparent)]
Request(#[from] ::job::JobError),
#[error("Job Profile resolution failed: {0}")]
Profile(#[from] manifest::ProfileError),
#[error("result-only Job cannot satisfy Profile requirements: {0}")]
UnsupportedProfile(String),
#[error("Job instruction resolution failed: {0}")]
Instruction(#[from] crate::SystemPromptError),
#[error("Job Prompt catalog resolution failed: {0}")]
Catalog(#[from] crate::CatalogError),
#[error("Job model configuration failed: {0}")]
Model(#[from] crate::ProviderError),
#[error("Job execution failed: {0}")]
Worker(String),
/// A sink error can follow durable acceptance. Hosts must reconcile their store and must
/// not classify this as a known retryable failure unless they can prove non-acceptance.
#[error(
"Host Job result acceptance is unconfirmed; reconcile the Host store before retrying: {0}"
)]
ResultUnconfirmed(String),
#[error("Job execution was cancelled without an accepted result")]
Cancelled,
#[error("Job ended without an accepted SubmitJobResult; final prose is not success")]
MissingResult,
}
/// Confirms only a synchronous, successful Host acceptance. Lifecycle/prose cannot create this.
#[derive(Debug)]
pub struct InternalJobResult {
pub submission: ::job::JobResultSubmission,
pub usage: Option<agen::timeline::event::UsageEvent>,
}
/// Resolved policy/model/prompt, with no running resources and no inherited authority.
pub struct PreparedInternalJob {
request: ::job::JobRequest,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
system_prompt: String,
features: FeatureRegistryBuilder,
finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
/// Explicit Host-supplied domain modules and the Profile requirements they satisfy.
/// Profile names never issue this grant. The Host must bind all module operations to
/// the immutable request/attempt and fence them on cancellation, timeout and shutdown.
#[derive(Default)]
pub struct JobFeatureGrant {
pub features: FeatureRegistryBuilder,
pub satisfied_requirements: Vec<&'static str>,
pub finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
pub fn prepare_job_with_grant(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
grant: JobFeatureGrant,
) -> Result<PreparedInternalJob, JobExecutionError> {
let registry = ProfileDiscovery::user_settings().discover()?;
prepare_job_from_registry_with_grant(request, cwd, client, &registry, grant)
}
/// Resolve exactly the requested registry Profile using the existing user settings registry.
/// `cwd` is a resolution base only; it never becomes filesystem or Workspace authority.
/// An injected client is useful for scripted executions and does not bypass policy validation.
pub fn prepare_job(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
) -> Result<PreparedInternalJob, JobExecutionError> {
let registry = ProfileDiscovery::user_settings().discover()?;
prepare_job_from_registry(request, cwd, client, &registry)
}
fn prepare_job_from_registry(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
registry: &manifest::ProfileRegistry,
) -> Result<PreparedInternalJob, JobExecutionError> {
prepare_job_from_registry_with_grant(request, cwd, client, registry, JobFeatureGrant::default())
}
fn prepare_job_from_registry_with_grant(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
registry: &manifest::ProfileRegistry,
grant: JobFeatureGrant,
) -> Result<PreparedInternalJob, JobExecutionError> {
request.validate()?;
let resolved = ProfileResolver::new()
.with_workspace_base(cwd)
.resolve_from_registry(
&request.profile_selector()?,
registry,
ProfileResolveOptions::with_worker_name("internal-job"),
)?;
let mut manifest = resolved.manifest;
validate_profile_with_grant(&manifest, &grant.satisfied_requirements)?;
if let Some(finalizer) = &grant.finalizer {
finalizer
.validate_profile(&manifest)
.map_err(JobExecutionError::UnsupportedProfile)?;
}
let mut tool_names = vec![SUBMIT_JOB_RESULT_TOOL.to_string()];
for descriptor in grant.features.descriptors() {
tool_names.extend(descriptor.tools.iter().map(|tool| tool.name.clone()));
}
let scope = Scope::empty();
let prompts = PromptCatalog::builtins_only()?;
let template = SystemPromptTemplate::parse(
&manifest.engine.instruction,
PromptCatalogSource::builtins_only(),
)?;
let mut system_prompt = template.render(&SystemPromptContext {
now: chrono::Utc::now(),
cwd: Cow::Borrowed("(no filesystem)"),
language: &manifest.engine.language,
scope: &scope,
tool_names,
feature_instructions: &[],
agents_md: None,
resident_summary: None,
prompts: &prompts,
})?;
system_prompt.push_str("\n\n");
system_prompt.push_str(&prompts.render(WorkerPrompt::JobSystem, minijinja::context! {})?);
// Host budgets attenuate Profile output/turn policy, never grant another capability.
manifest.engine.max_turns = std::num::NonZeroU32::new(
manifest
.engine
.max_turns
.map(|n| n.get())
.unwrap_or(MAX_JOB_TURNS)
.min(MAX_JOB_TURNS),
);
manifest.engine.max_tokens = Some(
manifest
.engine
.max_tokens
.unwrap_or(MAX_JOB_TOKENS)
.min(MAX_JOB_TOKENS),
);
let model_config = manifest::model_catalog::resolve_model_manifest(&manifest.model)
.map_err(crate::ProviderError::from)?;
let client = match client {
Some(client) => client,
None => crate::model_client::build_client_from_config(&model_config)?,
};
Ok(PreparedInternalJob {
request: request.clone(),
manifest,
client,
system_prompt,
features: grant.features,
finalizer: grant.finalizer,
})
}
impl PreparedInternalJob {
/// Run one immutable attempt. The sink must perform fenced, durable acceptance before
/// returning Ok. Timeout is Host-owned: cancel and then await this future for cleanup.
/// A result already accepted by the sink remains success even if the model subsequently
/// fails, reaches its turn limit or is cancelled; callers never rerun an accepted result.
pub async fn run<F>(
self,
attempt_id: String,
submit: Arc<dyn JobResultSink>,
on_cancel_sender: F,
) -> Result<InternalJobResult, JobExecutionError>
where
F: FnOnce(tokio::sync::mpsc::Sender<()>),
{
let mut feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?;
if let Some(finalizer) = self.finalizer {
feature =
feature.with_finalizer(finalizer, self.manifest.clone(), self.client.clone_boxed());
}
let accepted = feature.accepted();
let engine_policy = self.manifest.engine.clone();
let spec = InternalWorkerSpec {
identity: InternalWorkerIdentity {
kind: "job",
run_id: uuid::Uuid::now_v7(),
},
max_turns: engine_policy.max_turns.map(|n| n.get()),
engine_configurator: Some(Box::new(move |engine| {
crate::apply_worker_manifest(engine, &engine_policy);
})),
manifest: self.manifest,
client: self.client,
system_prompt: self.system_prompt,
input: self.request.worker_input(&attempt_id)?,
cache_key: None,
features: self.features.with_module(feature.clone()),
required_tools: &[SUBMIT_JOB_RESULT_TOOL],
authority: InternalWorkerAuthority {
workspace: WorkerWorkspaceContext::no_workspace(),
filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(),
workdir_session: None,
},
};
let outcome = run_internal_worker_with_cancel_sender(spec, on_cancel_sender).await;
let usage = match &outcome {
Ok(run) => run.usage.clone(),
Err(error) => error.usage.clone(),
};
if let Some(submission) = accepted.lock().expect("Job result state poisoned").clone() {
return Ok(InternalJobResult { submission, usage });
}
if let Some(error) = feature.unconfirmed() {
return Err(JobExecutionError::ResultUnconfirmed(error));
}
match outcome {
Err(error) => Err(JobExecutionError::Worker(error.source.to_string())),
Ok(run)
if matches!(
run.lifecycle,
WorkerRunResult::Cancelled | WorkerRunResult::RolledBack
) =>
{
Err(JobExecutionError::Cancelled)
}
Ok(_) => Err(JobExecutionError::MissingResult),
}
}
}
/// Validate requirements, not Profile names. Nothing configured as active is silently stripped.
fn validate_profile_with_grant(
manifest: &WorkerManifest,
satisfied: &[&str],
) -> Result<(), JobExecutionError> {
let manifest::FeatureConfig {
task,
memory,
subjektiv,
web,
image,
sub_worker,
flow,
worker,
workspace_worker_discovery,
objective,
manage_workdir,
workdir_catalog,
workspace_config,
ticket,
merge_request,
orchestration,
} = &manifest.feature;
let mut unavailable = Vec::new();
for (name, enabled) in [
("feature.task", task.enabled),
(
"feature.memory",
memory.profile.enabled
|| memory.profile.staging_tools
|| memory.workspace_settings.is_some(),
),
(
"feature.subjektiv",
subjektiv.profile.enabled
|| subjektiv.profile.consolidation_tools
|| subjektiv.workspace_settings.is_some(),
),
("feature.web", web.enabled),
("feature.image", image.enabled),
("feature.sub_worker", sub_worker.enabled),
("feature.flow", flow.enabled),
("feature.worker", worker.enabled),
(
"feature.workspace_worker_discovery",
workspace_worker_discovery.enabled,
),
("feature.objective", objective.enabled),
("feature.manage_workdir", manage_workdir.enabled),
("feature.workspace_config", workspace_config.enabled),
(
"feature.ticket",
ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow,
),
(
"feature.merge_request",
merge_request.show
|| merge_request.open
|| merge_request.review
|| merge_request.readiness_check
|| merge_request.complete,
),
("feature.orchestration", orchestration.enabled),
// The default WIP-only catalog is inert in Tools mode, not a tool requirement.
(
"feature.workdir_catalog",
workdir_catalog.enabled && manifest.worker.mode == manifest::WorkerMode::Wip,
),
(
"worker.mode=wip",
manifest.worker.mode == manifest::WorkerMode::Wip,
),
("scope", !manifest.scope.allow.is_empty()),
(
"delegation_scope",
!manifest.delegation_scope.allow.is_empty(),
),
("mcp", !manifest.mcp.stdio_servers.is_empty()),
("skills", manifest.skills.is_some()),
("compaction", manifest.compaction.is_some()),
(
"web",
manifest
.web
.as_ref()
.is_some_and(|config| config.enabled == Some(true)),
),
(
"session.record_event_trace",
manifest.session.record_event_trace,
),
] {
if enabled && !satisfied.contains(&name) {
unavailable.push(name);
}
}
if unavailable.is_empty() {
Ok(())
} else {
Err(JobExecutionError::UnsupportedProfile(
unavailable.join(", "),
))
}
}
#[cfg(test)]
mod tests;
+263
View File
@@ -0,0 +1,263 @@
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use super::JobExecutionError;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
pub const SUBMIT_JOB_RESULT_TOOL: &str = "SubmitJobResult";
const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input revision are immutable Host bindings. Only result is accepted as input; final prose is not success.";
/// Host acceptance authority. Implementations must validate the immutable fences and commit
/// acceptance durably before returning Ok. Err may mean an unknown outcome; identical retries
/// must be idempotent. This synchronous callback must be bounded and must not wait for async work.
pub trait JobResultSink: Send + Sync {
fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String>;
}
/// Consumer-owned asynchronous completion before durable result acceptance.
/// The generic runner owns no domain algorithms. This runs in the result tool,
/// never an orphaned background task, with the selected policy/model and a
/// cancellation token that must propagate into any child resource cleanup.
#[async_trait]
pub trait JobResultFinalizer: Send + Sync {
fn validate_profile(&self, manifest: &manifest::WorkerManifest) -> Result<(), String>;
async fn finalize(
&self,
result: Value,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
cancellation: crate::feature::background::BackgroundTaskCancellation,
) -> Result<Value, ToolError>;
}
#[derive(Clone)]
struct FinalizerContext {
finalizer: Arc<dyn JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SubmitJobResultInput {
result: Value,
}
#[derive(Default)]
struct SubmissionState {
sealed: Option<Value>,
prepared: Option<Value>,
unconfirmed: Option<String>,
}
/// Single-purpose FeatureModule; Profile names never install it or confer result authority.
#[derive(Clone)]
pub struct JobResultFeature {
job_id: String,
attempt_id: String,
input_revision: String,
max_result_bytes: u32,
sink: Arc<dyn JobResultSink>,
state: Arc<Mutex<SubmissionState>>,
accepted: Arc<Mutex<Option<::job::JobResultSubmission>>>,
cancelled: Arc<Mutex<HashSet<String>>>,
serial: Arc<tokio::sync::Mutex<()>>,
cancellation: crate::feature::background::BackgroundTaskCancellation,
finalizer: Option<FinalizerContext>,
}
impl JobResultFeature {
pub fn new(
request: &::job::JobRequest,
attempt_id: String,
sink: Arc<dyn JobResultSink>,
) -> Result<Self, JobExecutionError> {
request.validate()?;
::job::validate_identifier("attempt_id", &attempt_id, 512)?;
Ok(Self {
job_id: request.job_id.clone(),
attempt_id,
input_revision: request.input_revision.clone(),
max_result_bytes: request.limits.max_result_bytes,
sink,
state: Arc::default(),
accepted: Arc::default(),
cancelled: Arc::default(),
serial: Arc::default(),
cancellation: Default::default(),
finalizer: None,
})
}
pub fn with_finalizer(
mut self,
finalizer: Arc<dyn JobResultFinalizer>,
manifest: manifest::WorkerManifest,
client: Box<dyn agen::llm_client::LlmClient>,
) -> Self {
self.finalizer = Some(FinalizerContext {
finalizer,
manifest,
client,
});
self
}
pub(super) fn accepted(&self) -> Arc<Mutex<Option<::job::JobResultSubmission>>> {
self.accepted.clone()
}
pub(super) fn unconfirmed(&self) -> Option<String> {
self.state
.lock()
.expect("Job submission state poisoned")
.unconfirmed
.clone()
}
fn definition(&self) -> ToolDefinition {
let feature = self.clone();
Arc::new(move || {
(
ToolMeta::new(SUBMIT_JOB_RESULT_TOOL)
.description(DESCRIPTION)
.input_schema(
serde_json::to_value(schemars::schema_for!(SubmitJobResultInput))
.expect("Job result schema serializes"),
),
Arc::new(feature.clone()) as Arc<dyn Tool>,
)
})
}
}
#[async_trait]
impl Tool for JobResultFeature {
async fn execute(
&self,
input_json: &str,
context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SubmitJobResultInput = serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
::job::result_digest(&input.result, self.max_result_bytes)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let _serial = self.serial.lock().await;
if self.cancellation.is_cancelled() {
return Err(ToolError::Cancelled(ToolOutput {
summary: "Job result submission cancelled".into(),
content: None,
attachments: vec![],
}));
}
let cached = {
let state = self.state.lock().expect("Job submission state poisoned");
if state
.sealed
.as_ref()
.is_some_and(|sealed| sealed != &input.result)
{
return Err(ToolError::InvalidArgument(
"Job result is sealed; retry only the identical result".into(),
));
}
state.prepared.clone()
};
let prepared = if let Some(value) = cached {
value
} else if let Some(finalizer) = &self.finalizer {
finalizer
.finalizer
.finalize(
input.result.clone(),
finalizer.manifest.clone(),
finalizer.client.clone_boxed(),
self.cancellation.clone(),
)
.await?
} else {
input.result.clone()
};
::job::result_digest(&prepared, self.max_result_bytes)
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
let mut state = self.state.lock().expect("Job submission state poisoned");
if self
.cancelled
.lock()
.expect("Job cancellation state poisoned")
.contains(&context.execution_id())
{
return Err(ToolError::Cancelled(ToolOutput {
summary: "Job result submission cancelled".into(),
content: None,
attachments: vec![],
}));
}
if let Some(sealed) = &state.sealed {
if sealed != &input.result {
return Err(ToolError::InvalidArgument(
"Job result is sealed; retry only the identical result".into(),
));
}
} else {
// Seal before dispatch: a sink error can follow durable Host acceptance.
state.sealed = Some(input.result.clone());
state.prepared = Some(prepared.clone());
}
let submission = ::job::JobResultSubmission {
job_id: self.job_id.clone(),
attempt_id: self.attempt_id.clone(),
input_revision: self.input_revision.clone(),
result: prepared,
};
let mut accepted = self.accepted.lock().expect("Job result state poisoned");
if accepted.is_none() {
if let Err(error) = self.sink.submit(submission.clone()) {
let error = ::job::bounded_failure_detail(&error);
state.unconfirmed = Some(error.clone());
return Err(ToolError::ExecutionFailed(format!(
"Host Job result acceptance was not confirmed; retry only the identical result: {error}"
)));
}
state.unconfirmed = None;
*accepted = Some(submission);
}
Ok(ToolOutput {
summary: "Host Job result accepted".into(),
content: None,
attachments: vec![],
})
}
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancellation.cancel();
self.cancelled
.lock()
.expect("Job cancellation state poisoned")
.insert(context.execution_id());
Ok(())
}
}
impl FeatureModule for JobResultFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("host-job-result", "Host Job result")
.with_description("Result-only immutable Host Job submission capability.")
.with_tool(ToolDeclaration::new(SUBMIT_JOB_RESULT_TOOL, DESCRIPTION))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
SUBMIT_JOB_RESULT_TOOL,
self.definition(),
))
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,7 @@ pub mod fs_view;
pub mod hook;
pub(crate) mod in_flight;
pub mod ipc;
pub mod job;
pub mod model_client;
mod paste_artifact_tool;
pub mod prompt;
@@ -24,6 +25,7 @@ pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
pub mod spawn;
pub mod subjektiv;
pub mod wip;
mod internal_worker;
+3
View File
@@ -97,6 +97,7 @@ pub enum WorkerPrompt {
SubjektivMemoryConsolidationSystem,
SubjektivMemorySurfaceSystem,
FlowVerifierSystem,
JobSystem,
BackendJobSystem,
NotifyWrapper,
InterruptToolResultSummary,
@@ -121,6 +122,7 @@ impl WorkerPrompt {
}
Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system",
Self::FlowVerifierSystem => "internal.flow_verifier_system",
Self::JobSystem => "internal.job_system",
Self::BackendJobSystem => "internal.backend_job_system",
Self::NotifyWrapper => "internal.notify_wrapper",
Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary",
@@ -144,6 +146,7 @@ impl WorkerPrompt {
WorkerPrompt::SubjektivMemoryConsolidationSystem,
WorkerPrompt::SubjektivMemorySurfaceSystem,
WorkerPrompt::FlowVerifierSystem,
WorkerPrompt::JobSystem,
WorkerPrompt::BackendJobSystem,
WorkerPrompt::NotifyWrapper,
WorkerPrompt::InterruptToolResultSummary,
+3 -1
View File
@@ -712,7 +712,9 @@ impl SessionCapture {
fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance {
match origin {
WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput,
WorkerHistoryProvenance::HumanInput { .. } | WorkerHistoryProvenance::LocalHumanInput => {
SessionEntryProvenance::HumanInput
}
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
WorkerHistoryProvenance::BackendInstruction { .. } => {
+700
View File
@@ -0,0 +1,700 @@
//! Explicit Host capability for subject-scoped Memory and committed Sessions.
//!
//! A manifest is policy, not a connection. Hosts bind Subject/storage authority
//! in their implementation; the Worker supplies its own Worker/Session context.
//! Neither Subject identity nor filesystem/Workspace authority is model input.
pub use crate::feature::builtin::memory::SubjektivConsolidationToolsFeature;
pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
use std::sync::Arc;
use std::time::Duration;
use agen::tool::ToolError;
use memory::backend::{
MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryStageCandidateOperation,
};
use server_api::*;
use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation};
/// Host-issued execution context, never deserialized from model arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostContext {
pub worker_id: String,
pub session_id: String,
}
/// Explicit local configuration. This is not a trusted Workspace snapshot and
/// is not persisted in the reusable Worker manifest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostSettings {
pub language: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SubjektivHostError {
#[error("{message}")]
Conflict { code: String, message: String },
#[error("{0}")]
InvalidArgument(String),
#[error("{0}")]
Unavailable(String),
}
impl From<SubjektivHostError> for ToolError {
fn from(error: SubjektivHostError) -> Self {
match error {
SubjektivHostError::Conflict { code, message } => {
Self::StructuredConflict { code, message }
}
SubjektivHostError::InvalidArgument(message) => Self::InvalidArgument(message),
SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message),
}
}
}
/// Object-safe capability implemented by Backend and standalone Hosts.
/// Implementations must enforce their bound Subject, Worker/Session authority,
/// committed evidence and (for Jobs) immutable batch attenuation. Knowing an
/// identifier is not a grant. No method grants ambient filesystem access.
pub trait SubjektivHost: Send + Sync + 'static {
fn settings(&self) -> SubjektivHostSettings;
/// Separate explicit grant for decision/surface execution; Profile policy
/// alone must never authorize consolidation tools.
fn consolidation_granted(&self) -> bool {
false
}
/// Optional real Backend identity, used only for prompt provenance.
fn workspace_id(&self) -> Option<String> {
None
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError>;
fn session(
&self,
context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError>;
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError>;
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError>;
fn request_consolidation(
&self,
context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError>;
}
/// Backend adapter. Existing authenticated Workspace operations and snapshot
/// validation remain the authority; no local Workspace is synthesized.
pub struct BackendSubjektivHost {
client: Arc<dyn WorkspaceClient>,
settings: SubjektivHostSettings,
workspace_id: String,
consolidation_granted: bool,
}
impl BackendSubjektivHost {
pub fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Arc<dyn SubjektivHost>>> {
config.validate_execution().map_err(std::io::Error::other)?;
if !config.execution_enabled() {
return Ok(None);
}
let settings = config
.workspace_settings()
.expect("execution requires trusted settings");
if !client.is_available() || client.workspace_id() != Some(settings.workspace_id.as_str()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv requires available Backend authority matching trusted Workspace settings",
));
}
Ok(Some(Arc::new(Self {
client,
workspace_id: settings.workspace_id,
settings: SubjektivHostSettings {
language: settings.language,
},
consolidation_granted: config.profile.consolidation_tools,
})))
}
fn execute<T: serde::de::DeserializeOwned>(
&self,
operation: WorkspaceServerOperation,
) -> Result<T, SubjektivHostError> {
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
decode(response)
}
}
fn decode<T: serde::de::DeserializeOwned>(
response: crate::WorkspaceResponse,
) -> Result<T, SubjektivHostError> {
if !response.is_success() {
let parsed = serde_json::from_str::<RepositoryApiError>(&response.body).ok();
let message = parsed
.as_ref()
.map(|error| error.message.clone())
.unwrap_or(response.body);
if let Some(code) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "stale_cursor"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| diagnostic.code.clone())
}) {
return Err(SubjektivHostError::Conflict { code, message });
}
return Err(if response.status == 409 {
SubjektivHostError::Conflict {
code: "conflict".into(),
message,
}
} else if matches!(response.status, 400 | 404 | 422) {
SubjektivHostError::InvalidArgument(message)
} else {
SubjektivHostError::Unavailable(message)
});
}
serde_json::from_str(&response.body).map_err(|error| {
SubjektivHostError::Unavailable(format!("decode subjektiv response: {error}"))
})
}
impl SubjektivHost for BackendSubjektivHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: self.settings.language.clone(),
}
}
fn workspace_id(&self) -> Option<String> {
Some(self.workspace_id.clone())
}
fn consolidation_granted(&self) -> bool {
self.consolidation_granted
}
fn memory(
&self,
_context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
// Preserve the bounded resident-context request timeout.
if matches!(
operation,
SubjektivMemoryBackendOperation::ResidentContext(_)
) {
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/memory", self.workspace_id),
serde_json::to_string(&SubjektivMemoryBackendRequest { operation })
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
return decode(
self.client
.execute_with_timeout(request, Duration::from_secs(5))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
}
self.execute(WorkspaceServerOperation::SubjektivMemory(
SubjektivMemoryBackendRequest { operation },
))
}
fn session(
&self,
_context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
// Session domain diagnostics are typed even on non-2xx responses.
if let Ok(value) = serde_json::from_str(&response.body) {
return Ok(value);
}
decode(response)
}
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivStageCandidate(
SubjektivStageCandidateRequest {
session_id: context.session_id.clone(),
operation,
},
))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: context.session_id.clone(),
create_if_missing,
},
))
}
fn request_consolidation(
&self,
_context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/consolidation", self.workspace_id),
serde_json::to_string(&MemoryConsolidateStagingRequest {
force: operation.force,
})
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
let response: MemoryConsolidationResponse = decode(response)?;
Ok(MemoryConsolidationOutput {
status: response.status,
summary: response.summary,
candidate_count: response.candidate_count,
total_bytes: response.total_bytes,
})
}
}
/// Live connection with Worker-supplied authority. Not serializable and never
/// inherited by an ordinary SubWorker.
#[derive(Clone)]
pub struct SubjektivHostConnection {
pub host: Arc<dyn SubjektivHost>,
pub context: SubjektivHostContext,
}
impl SubjektivHostConnection {
pub fn new(
host: Arc<dyn SubjektivHost>,
context: SubjektivHostContext,
) -> std::io::Result<Self> {
let language = host.settings().language;
if context.worker_id.trim().is_empty()
|| context.session_id.trim().is_empty()
|| context.worker_id.chars().any(char::is_control)
|| context.session_id.chars().any(char::is_control)
|| !manifest::is_normalized_workspace_memory_language(&language)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid subjektiv Host context/settings",
));
}
Ok(Self { host, context })
}
pub fn memory(
&self,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
if let SubjektivMemoryBackendOperation::StageExplicit(input) = &operation
&& input.session_id != self.context.session_id
{
return Err(SubjektivHostError::InvalidArgument(
"explicit staging targets a foreign Session".into(),
));
}
if matches!(
operation,
SubjektivMemoryBackendOperation::DecideCandidate(_)
| SubjektivMemoryBackendOperation::PrepareSurface(_)
| SubjektivMemoryBackendOperation::PublishSurface(_)
| SubjektivMemoryBackendOperation::FailSurface(_)
) && !self.host.consolidation_granted()
{
return Err(SubjektivHostError::InvalidArgument(
"Host did not grant consolidation".into(),
));
}
self.host.memory(&self.context, operation)
}
pub fn session(
&self,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
self.host.session(&self.context, operation)
}
pub async fn prepare_subjektiv_memory_surface(
&self,
) -> Result<SubjektivSurfacePrepareResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PrepareSurface(
SubjektivSurfacePrepareRequest {},
))? {
SubjektivMemoryBackendResponse::SurfacePrepared(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface preparation response".into(),
)),
}
}
pub async fn publish_subjektiv_memory_surface(
&self,
input: SubjektivSurfacePublishRequest,
) -> Result<SubjektivSurfacePublishResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PublishSurface(input))? {
SubjektivMemoryBackendResponse::SurfacePublished(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface publication response".into(),
)),
}
}
pub async fn fail_subjektiv_memory_surface(
&self,
input: SubjektivSurfaceFailureRequest,
) -> Result<SubjektivSurfaceFailureResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::FailSurface(input))? {
SubjektivMemoryBackendResponse::SurfaceFailed(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface failure response".into(),
)),
}
}
}
#[cfg(test)]
pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHostConnection {
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
config.profile.enabled = true;
config.profile.consolidation_tools = true;
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: client.workspace_id().unwrap().to_string(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
SubjektivHostConnection::new(
BackendSubjektivHost::from_resolved_config(&config, client)
.unwrap()
.unwrap(),
SubjektivHostContext {
worker_id: "worker-test".into(),
session_id: "session-1".into(),
},
)
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport,
WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext,
};
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures_util::Stream;
use session_store::{CombinedStore, FsStore, FsWorkerStore, WorkerMetadataStore};
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Default)]
struct LocalHost {
contexts: Mutex<Vec<SubjektivHostContext>>,
attributed: Mutex<Vec<(SubjektivHostContext, bool)>>,
}
impl SubjektivHost for LocalHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: "English".into(),
}
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
self.contexts.lock().unwrap().push(context.clone());
match operation {
SubjektivMemoryBackendOperation::ResidentContext(_) => {
Ok(SubjektivMemoryBackendResponse::ResidentContext(
SubjektivResidentContextOutput {
behavior_md: "Explicit local subject behavior.".into(),
behavior_revision: 1,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability:
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
content: None,
},
},
))
}
_ => Err(SubjektivHostError::Unavailable(
"not granted by test Host".into(),
)),
}
}
fn session(
&self,
_: &SubjektivHostContext,
_: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn stage_candidate(
&self,
_: &SubjektivHostContext,
_: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.attributed
.lock()
.unwrap()
.push((context.clone(), create_if_missing));
Ok(SubjektivRecordSessionResponse {
subject_id: "test-owned-local-subject".into(),
session_id: context.session_id.clone(),
})
}
fn request_consolidation(
&self,
_: &SubjektivHostContext,
_: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("no candidates".into()))
}
}
#[derive(Clone)]
struct ScriptedClient(tokio::sync::mpsc::UnboundedSender<Request>);
#[async_trait]
impl LlmClient for ScriptedClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.0.send(request).unwrap();
Ok(Box::pin(futures_util::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Done.")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn launch_case(enabled: bool, attach: bool) {
let temp = tempfile::tempdir().unwrap();
let _runtime_sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let name = format!("subjektiv-host-{}", uuid::Uuid::now_v7());
let mut manifest = WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "{name}"
[model]
scheme = "anthropic"
model_id = "scripted-model"
[engine]
[scope]
allow = []
[feature.task]
enabled = true
"#
))
.unwrap();
manifest.feature.subjektiv.profile.enabled = enabled;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest.feature.subjektiv.profile.extraction.threshold = Some(u64::MAX);
assert!(manifest.feature.subjektiv.workspace_settings.is_none());
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let host = Arc::new(LocalHost::default());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let bootstrap = WorkerBootstrap::new(
manifest,
store.clone(),
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
.with_model_client(ScriptedClient(tx));
let bootstrap = if attach {
bootstrap.with_subjektiv_host(host.clone())
} else {
bootstrap
};
let prepared = bootstrap.prepare().await.unwrap();
let expected = SubjektivHostContext {
worker_id: name.clone(),
session_id: prepared.worker().session_id().to_string(),
};
assert!(
prepared
.worker()
.manifest()
.feature
.subjektiv
.workspace_settings
.is_none()
);
assert!(prepared.worker().workspace_id().is_none());
assert!(
prepared
.worker()
.workspace_client()
.workspace_id()
.is_none()
);
let controller = prepared.start().await.unwrap();
controller
.handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
.await
.unwrap();
let request = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
let tools = request
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(
tools.contains(&"TaskCreate"),
"ordinary Features must survive"
);
for name in [
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryRemember",
"SubjektivSessionList",
"SubjektivSessionRead",
] {
assert_eq!(tools.contains(&name), enabled && attach, "{name}");
}
assert!(!tools.contains(&"MemoryApplyCandidate"));
let rendered = format!(
"{}\n{}",
request.system_prompt.as_deref().unwrap_or_default(),
request
.items
.iter()
.filter_map(|item| item.as_text())
.collect::<Vec<_>>()
.join("\n")
);
assert_eq!(
rendered.contains("Explicit local subject behavior."),
enabled && attach
);
if enabled && attach {
assert_eq!(
*host.attributed.lock().unwrap(),
vec![(expected.clone(), true)]
);
assert!(
host.contexts
.lock()
.unwrap()
.iter()
.all(|context| context == &expected)
);
let metadata = store.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
metadata.subjektiv_session_attribution,
Some(session_store::SubjektivSessionAttributionState::Confirmed { .. })
));
} else {
assert!(host.contexts.lock().unwrap().is_empty());
assert!(host.attributed.lock().unwrap().is_empty());
}
controller
.handle
.send(Method::Shutdown {
command: protocol::WorkerCommandEnvelope::new(1),
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), controller.shutdown)
.await
.unwrap()
.unwrap();
controller.controller_task.await.unwrap();
}
#[tokio::test]
async fn explicit_local_host_installs_ordinary_features_without_workspace_snapshot() {
launch_case(true, true).await;
}
#[tokio::test]
async fn enabled_policy_without_host_is_inert() {
launch_case(true, false).await;
}
#[tokio::test]
async fn disabled_policy_with_host_has_no_subjektiv_side_effects() {
launch_case(false, true).await;
}
#[test]
fn consolidation_installation_requires_separate_explicit_host_grant() {
let connection = SubjektivHostConnection::new(
Arc::new(LocalHost::default()),
SubjektivHostContext {
worker_id: "test-worker".into(),
session_id: "test-session".into(),
},
)
.unwrap();
assert!(SubjektivConsolidationToolsFeature::new(connection).is_err());
}
}
+70 -8
View File
@@ -2426,7 +2426,7 @@ fn one() -> u32 {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct WipInspectInput {
/// Opaque interface reference returned by WipDiscover.
/// Opaque interface reference returned by Discover.
interface: String,
/// Explicitly supersede the cached descriptor observation.
#[serde(default)]
@@ -2435,11 +2435,11 @@ struct WipInspectInput {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct WipCallInput {
/// Exact object path returned by WipDiscover.
/// Exact object path returned by Discover.
path: String,
/// Exact interface reference selected from that object.
interface: String,
/// Exact operation name from WipInspect.
/// Exact operation name from Inspect.
operation: String,
/// Original operation arguments. Compatibility operations accept the original tool object here.
arguments: Json,
@@ -2521,7 +2521,7 @@ fn wip_tool_definitions(runtime: Arc<WipRuntime>) -> Vec<ToolDefinition> {
Arc::new(move || {
let schema = schemars::schema_for!(WipDiscoverInput);
(
ToolMeta::new("WipDiscover")
ToolMeta::new("Discover")
.description("Explore the authorized WIP Worldspace and stateful Known Space. Start at `/`; use reset after reconnect or authority changes.")
.input_schema(serde_json::to_value(schema).expect("WIP discover schema serializes")),
Arc::new(WipDiscoverTool {
@@ -2532,8 +2532,8 @@ fn wip_tool_definitions(runtime: Arc<WipRuntime>) -> Vec<ToolDefinition> {
Arc::new(move || {
let schema = schemars::schema_for!(WipInspectInput);
(
ToolMeta::new("WipInspect")
.description("Fetch and validate one descriptor returned by WipDiscover. Inspect before calling an operation.")
ToolMeta::new("Inspect")
.description("Fetch and validate one descriptor returned by Discover. Inspect before calling an operation.")
.input_schema(serde_json::to_value(schema).expect("WIP inspect schema serializes")),
Arc::new(WipInspectTool {
runtime: Arc::clone(&inspect_runtime),
@@ -2543,7 +2543,7 @@ fn wip_tool_definitions(runtime: Arc<WipRuntime>) -> Vec<ToolDefinition> {
Arc::new(move || {
let schema = schemars::schema_for!(WipCallInput);
(
ToolMeta::new("WipCall")
ToolMeta::new("Call")
.description("Invoke an operation only from fresh object/interface observations. Stale validators and invalid original JSON Schema inputs fail closed; unknown write outcomes must not be retried automatically.")
.input_schema(serde_json::to_value(schema).expect("WIP call schema serializes")),
Arc::new(WipCallTool {
@@ -3093,12 +3093,74 @@ mod tests {
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>(),
["WipCall", "WipDiscover", "WipInspect"]
["Call", "Discover", "Inspect"]
);
let metrics = runtime.metrics();
assert!(metrics.ordinary_schema_bytes > metrics.wip_schema_bytes);
}
#[tokio::test]
async fn renamed_gateway_tools_discover_inspect_and_call_through_registered_surface() {
let calls = Arc::new(AtomicUsize::new(0));
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls)));
install_wip_mode(&mut engine, None, "worker-a".into()).unwrap();
let tools = engine.tool_server_handle();
tools.flush_pending();
let discovered = transport_output_json(
tools
.call_tool(
"Discover",
r#"{"path":"/","depth":2}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap(),
);
let object = discovered["known_space"]
.as_array()
.unwrap()
.iter()
.find(|item| item["path"] == "/tools/Echo")
.unwrap();
assert_eq!(object["state"], "fresh");
let interface = object["object"]["interfaces"][0].as_str().unwrap();
let inspected = transport_output_json(
tools
.call_tool(
"Inspect",
&json!({"interface": interface}).to_string(),
ToolExecutionContext::direct(),
)
.await
.unwrap(),
);
assert_eq!(inspected["interface"], interface);
assert_eq!(inspected["state"], "fresh");
assert_eq!(inspected["descriptor"]["operations"][0]["name"], "call");
let arguments = json!({"message": "hello"});
let output = tools
.call_tool(
"Call",
&json!({
"path": object["path"],
"interface": interface,
"operation": "call",
"arguments": arguments,
})
.to_string(),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(output.summary, arguments.to_string());
assert!(output.content.is_none());
assert!(output.attachments.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn mount_collision_and_native_replacement_are_explicit() {
struct Native;
+103 -3
View File
@@ -357,6 +357,9 @@ pub(crate) fn authenticated_input_provenance(
) -> WorkerHistoryProvenance {
match source {
protocol::AuthenticatedInputSource::UntrustedWire => WorkerHistoryProvenance::LegacyUnknown,
protocol::AuthenticatedInputSource::LocalOperator => {
WorkerHistoryProvenance::LocalHumanInput
}
protocol::AuthenticatedInputSource::Account { account_id } => {
WorkerHistoryProvenance::HumanInput {
account_id: account_id.clone(),
@@ -2752,6 +2755,7 @@ pub struct Worker<C: LlmClient, St: Store> {
inject_resident_summary: bool,
/// Deferred resident prompt source installed by an enabled Feature.
feature_resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
subjektiv_host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>,
/// One-shot generic Feature lifecycle notification required after restore.
restore_feature_lifecycle_pending: bool,
/// Complete system prompt replacement installed by an enabled Feature.
@@ -3014,6 +3018,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -3233,6 +3238,49 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.workspace_context.client_handle()
}
/// Bind a live capability before Feature installation. This is deliberately
/// separate from Workspace authority and is not inherited by SubWorkers.
pub fn bind_subjektiv_host(&mut self, host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>) {
self.subjektiv_host = host;
}
pub fn subjektiv_host_connection(
&self,
) -> std::io::Result<Option<crate::subjektiv::SubjektivHostConnection>> {
use crate::subjektiv::{
BackendSubjektivHost, SubjektivHostConnection, SubjektivHostContext,
};
let config = &self.manifest.feature.subjektiv;
config.validate_execution().map_err(std::io::Error::other)?;
if !config.profile.enabled {
return Ok(None);
}
let host = if let Some(host) = &self.subjektiv_host {
if config.workspace_settings.is_some()
|| self.workspace_id().is_some()
|| self.workspace_client().workspace_id().is_some()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"explicit local subjektiv Host cannot replace trusted Backend Workspace authority",
));
}
Some(host.clone())
} else {
BackendSubjektivHost::from_resolved_config(config, self.workspace_client_handle())?
};
host.map(|host| {
SubjektivHostConnection::new(
host,
SubjektivHostContext {
worker_id: self.manifest.worker.name.clone(),
session_id: self.session_id().to_string(),
},
)
})
.transpose()
}
/// Bind the host-owned Worker-session observation projection. The provider
/// is responsible for workspace authorization and per-capture revalidation.
pub fn bind_worker_observation_provider(
@@ -7324,6 +7372,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -7433,6 +7482,50 @@ where
create_if_missing,
initial_diagnostic.to_string(),
))?;
let output = if self.subjektiv_host.is_some() {
let connection = self
.subjektiv_host_connection()
.map_err(|error| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: error.to_string(),
})?
.ok_or_else(|| WorkerError::SubjektivSessionAttribution {
state: "failed",
message: "Session attribution requires enabled explicit Host capability".into(),
})?;
match connection
.host
.record_session(&connection.context, create_if_missing)
{
Ok(output) => output,
Err(error) => {
let message = bounded_attribution_diagnostic(error.to_string());
let rejected =
!matches!(error, crate::subjektiv::SubjektivHostError::Unavailable(_));
let state = if create_if_missing && rejected {
SubjektivSessionAttributionState::Failed {
session_id,
diagnostic: message.clone(),
}
} else {
incomplete_subjektiv_attribution_state(
session_id,
create_if_missing,
message.clone(),
)
};
self.persist_subjektiv_session_attribution(state)?;
return Err(WorkerError::SubjektivSessionAttribution {
state: if create_if_missing && rejected {
"failed"
} else {
incomplete_subjektiv_attribution_label(create_if_missing)
},
message,
});
}
}
} else {
let response = match self.workspace_client().execute_server_operation(
WorkspaceServerOperation::SubjektivRecordSession(
server_api::SubjektivRecordSessionRequest {
@@ -7505,6 +7598,8 @@ where
});
}
};
output
};
if output.session_id != session_id.to_string()
|| output.subject_id.trim().is_empty()
|| output.subject_id.chars().any(char::is_control)
@@ -7616,6 +7711,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -7744,6 +7840,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -8130,6 +8227,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: true,
feature_system_prompt_override: None,
user_segments: state.user_segments,
@@ -9852,7 +9950,7 @@ mod build_summary_prompt_tests {
});
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
manifest,
client,
Some(crate::subjektiv::test_connection(client)),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
)
.unwrap()
@@ -14731,12 +14829,14 @@ permission = "write"
let resident_source = || {
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
&manifest,
Arc::new(RestoreSubjectResidentClient {
Some(crate::subjektiv::test_connection(Arc::new(
RestoreSubjectResidentClient {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("literal /prepare()".into()),
load_count: Arc::default(),
behavior_revision: Some(behavior_revision.clone()),
}),
},
))),
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
)
.unwrap()
+3
View File
@@ -16,8 +16,11 @@ axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
config-source.workspace = true
feature-storage.workspace = true
subjektiv.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
job.workspace = true
protocol = { workspace = true }
runtime-api.workspace = true
server-api.workspace = true
+385 -150
View File
@@ -7,26 +7,28 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use worker_runtime::identity::RuntimeWorkerRef;
use crate::{Error, Result};
pub const BACKEND_JOB_RUNTIME_ID: &str = crate::hosts::EMBEDDED_RUNTIME_ID;
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
pub use job::{
ABSOLUTE_MAX_ATTEMPTS, ABSOLUTE_MAX_CONCURRENT_JOBS, ABSOLUTE_MAX_RESULT_BYTES,
DEFAULT_JOB_TIMEOUT_SECONDS, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_CONCURRENT_JOBS,
DEFAULT_MAX_RESULT_BYTES, JobResultSubmission as BackendJobResultSubmission,
MAX_JOB_DELIVERY_BYTES, MAX_JOB_FAILURE_BYTES, MAX_JOB_INPUT_BYTES, MAX_JOB_INSTRUCTION_BYTES,
MAX_JOB_PURPOSE_BYTES, MAX_JOB_REFERENCE_BYTES, MAX_JOB_TIMEOUT_SECONDS, attempt_id,
bounded_failure_detail,
};
// Preserve the Backend InvalidInput surface and its exact historical messages.
fn backend_error(error: job::JobError) -> Error {
match error {
job::JobError::InvalidInput(detail) => {
Error::InvalidInput(detail.replacen("Job ", "Backend Job ", 1))
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -37,6 +39,17 @@ pub enum BackendJobState {
Unknown,
}
impl From<BackendJobState> for job::JobState {
fn from(state: BackendJobState) -> Self {
match state {
BackendJobState::Pending => Self::Pending,
BackendJobState::Completed => Self::Completed,
BackendJobState::Failed => Self::Failed,
BackendJobState::Unknown => Self::Unknown,
}
}
}
impl BackendJobState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -69,6 +82,19 @@ pub enum BackendJobAttemptState {
Unknown,
}
impl From<BackendJobAttemptState> for job::JobAttemptState {
fn from(state: BackendJobAttemptState) -> Self {
match state {
BackendJobAttemptState::Reserved => Self::Reserved,
BackendJobAttemptState::Dispatching => Self::Dispatching,
BackendJobAttemptState::Dispatched => Self::Dispatched,
BackendJobAttemptState::Completed => Self::Completed,
BackendJobAttemptState::Failed => Self::Failed,
BackendJobAttemptState::Unknown => Self::Unknown,
}
}
}
impl BackendJobAttemptState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -161,28 +187,29 @@ impl Default for BackendJobLimits {
impl BackendJobLimits {
pub fn validate(&self) -> Result<()> {
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
{
return Err(Error::InvalidInput(format!(
"Backend Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
)));
job::JobLimits::from(self).validate().map_err(backend_error)
}
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
return Err(Error::InvalidInput(format!(
"Backend Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
)));
}
impl From<&BackendJobLimits> for job::JobLimits {
fn from(limits: &BackendJobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
)));
}
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
return Err(Error::InvalidInput(format!(
"Backend Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
)));
}
impl From<job::JobLimits> for BackendJobLimits {
fn from(limits: job::JobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
Ok(())
}
}
@@ -261,17 +288,29 @@ pub struct BackendJobRequest {
}
impl BackendJobRequest {
pub fn validate(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
validate_identifier("profile", &self.profile, 256)?;
self.profile_selector()?;
if let Some(key) = &self.serialization_key {
validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?;
/// Adapt neutral intent without granting domain access or writer/notification
/// authority. Validation remains the Backend submission boundary's job;
/// the caller-selected profile is retained exactly, with no fallback.
pub fn from_job_request(request: job::JobRequest) -> Self {
Self {
job_id: request.job_id,
purpose: request.purpose,
input_revision: request.input_revision,
input_ref: request.input_ref,
input: request.input,
instruction: request.instruction,
profile: request.profile,
grants: BackendJobGrants::default(),
serialization_key: request.serialization_key,
source_worker: None,
notification_target: None,
limits: request.limits.into(),
}
}
pub fn validate(&self) -> Result<()> {
let request = self.to_job_request();
request.validate_fields().map_err(backend_error)?;
if let Some(grant) = &self.grants.subjektiv_consolidation {
validate_identifier("grant subject_id", &grant.subject_id, 256)?;
if grant.candidate_ids.len() > 256 {
@@ -297,44 +336,30 @@ impl BackendJobRequest {
));
}
}
let input = serde_json::to_vec(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
if input.len() > MAX_JOB_INPUT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
)));
}
request.validate_input().map_err(backend_error)?;
self.limits.validate()
}
/// Transport-neutral projection only. Domain grants, writer authority and
/// derived resource locks remain Backend-owned; never fingerprint this
/// projection as a substitute for the full Backend intent.
pub fn to_job_request(&self) -> job::JobRequest {
job::JobRequest {
job_id: self.job_id.clone(),
purpose: self.purpose.clone(),
input_revision: self.input_revision.clone(),
input_ref: self.input_ref.clone(),
input: self.input.clone(),
instruction: self.instruction.clone(),
profile: self.profile.clone(),
serialization_key: self.serialization_key.clone(),
limits: (&self.limits).into(),
}
}
/// Validate selector syntax only. Registry existence and resolution are dispatch authority.
pub fn profile_selector(&self) -> Result<manifest::ProfileSelector> {
use manifest::ProfileSelector;
validate_identifier("profile", &self.profile, 256)?;
let selector = ProfileSelector::parse_cli(&self.profile);
let valid = match &selector {
ProfileSelector::Default => true,
ProfileSelector::Path { .. } => false,
ProfileSelector::Named { source, name } => {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains(['/', '\\', ':'])
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& ![".toml", ".json", ".dcdl", ".nix"]
.iter()
.any(|suffix| name.ends_with(suffix))
&& (source.is_some() || name != "inherit")
}
};
if !valid {
return Err(Error::InvalidInput(
"Backend Job profile must be a manifest registry selector, not raw source or a path".into(),
));
}
Ok(selector)
job::profile_selector(&self.profile).map_err(backend_error)
}
/// Effective single-flight key. Typed grants cannot bypass their subject fence.
@@ -348,20 +373,12 @@ impl BackendJobRequest {
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
let encoded = serde_json::to_vec(self).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job intent: {error}"))
})?;
Ok(sha256(&encoded))
job::fingerprint(self).map_err(backend_error)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
let input = serde_json::to_string(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
Ok(format!(
"{}\n\nBackend Job envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority.",
self.instruction, self.job_id, attempt_id, self.input_revision, self.input_ref, input
))
job::worker_input_with_label(&self.to_job_request(), attempt_id, "Backend Job")
.map_err(backend_error)
}
}
@@ -451,13 +468,10 @@ pub struct BackendJobReservation {
pub resource_reused: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BackendJobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
pub result: Value,
impl BackendJobReservation {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -467,6 +481,28 @@ pub struct BackendJobResultAcceptance {
pub replayed: bool,
}
impl BackendJobResultAcceptance {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
// Project the durable Job outcome, not Worker lifecycle, cleanup or delivery.
// The paired attempt supplies its own identity, number and execution state.
fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome {
job::JobOutcome {
job_id: job.request.job_id.clone(),
input_revision: job.request.input_revision.clone(),
attempt_id: attempt.attempt_id.clone(),
attempt: attempt.attempt,
state: job.state.into(),
attempt_state: attempt.state.into(),
result: job.result.clone(),
failure_category: job.failure_category.clone(),
failure_detail: job.failure_detail.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendJobWorkerBinding {
pub job_id: String,
@@ -489,10 +525,6 @@ pub struct BackendJobDeliveryRecord {
pub delivered_at: Option<String>,
}
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
format!("{job_id}:attempt:{attempt}")
}
pub fn allocation_key(attempt_id: &str) -> String {
format!("backend-job:{attempt_id}")
}
@@ -515,64 +547,15 @@ pub fn delivery_id(job_id: &str, attempt_id: &str) -> String {
}
fn tracked_request_id(kind: &str, identity: &[u8]) -> String {
format!("backend-job-{kind}:{}", sha256(identity))
format!("backend-job-{kind}:{}", job::sha256(identity))
}
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
let encoded = serde_json::to_string(result)
.map_err(|error| Error::InvalidInput(format!("serialize Backend Job result: {error}")))?;
if encoded.len() > max_bytes as usize {
return Err(Error::InvalidInput(format!(
"Backend Job result exceeds {max_bytes} bytes"
)));
}
Ok((sha256(encoded.as_bytes()), encoded))
}
pub(crate) fn bounded_failure_detail(detail: &str) -> String {
if detail.len() <= MAX_JOB_FAILURE_BYTES {
return detail.to_string();
}
let mut end = MAX_JOB_FAILURE_BYTES;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail[..end].to_string()
job::result_digest(result, max_bytes).map_err(backend_error)
}
fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
validate_bounded_text(field, value, max_bytes)?;
if value.chars().any(char::is_control) {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must not contain control characters"
)));
}
Ok(())
}
fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
if value.is_empty() || value.trim() != value {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must be non-empty and trimmed"
)));
}
if value.len() > max_bytes {
return Err(Error::InvalidInput(format!(
"Backend Job {field} exceeds {max_bytes} bytes"
)));
}
Ok(())
}
fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(7 + digest.len() * 2);
output.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
job::validate_identifier(field, value, max_bytes).map_err(backend_error)
}
#[cfg(test)]
@@ -596,6 +579,258 @@ mod tests {
}
}
fn reservation() -> BackendJobReservation {
serde_json::from_value(serde_json::json!({
"job": {
"workspace_id": "workspace-a",
"request": request(),
"intent_fingerprint": request().fingerprint().unwrap(),
"state": "pending",
"current_attempt": 2,
"created_at": "created",
"updated_at": "updated"
},
"attempt": {
"workspace_id": "workspace-a",
"job_id": "ticket-check:T-1:r7",
"attempt_id": "ticket-check:T-1:r7:attempt:2",
"attempt": 2,
"input_revision": "7",
"state": "reserved",
"worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"},
"runtime_run_id": "run-a",
"deadline_at": "deadline",
"created_at": "created",
"updated_at": "updated"
},
"replayed": false,
"resource_reused": false
}))
.unwrap()
}
#[test]
fn backend_state_projections_preserve_existing_wire_vocabulary() {
for state in [
BackendJobState::Pending,
BackendJobState::Completed,
BackendJobState::Failed,
BackendJobState::Unknown,
] {
let shared = job::JobState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
for state in [
BackendJobAttemptState::Reserved,
BackendJobAttemptState::Dispatching,
BackendJobAttemptState::Dispatched,
BackendJobAttemptState::Completed,
BackendJobAttemptState::Failed,
BackendJobAttemptState::Unknown,
] {
let shared = job::JobAttemptState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
// The shared read vocabulary does not add Backend cancellation support.
assert!(BackendJobState::parse("cancelled").is_err());
assert!(BackendJobAttemptState::parse("cancelled").is_err());
assert!(serde_json::from_str::<BackendJobState>("\"cancelled\"").is_err());
assert!(serde_json::from_str::<BackendJobAttemptState>("\"cancelled\"").is_err());
}
#[test]
fn neutral_request_round_trip_does_not_infer_authority_or_replace_profile() {
for profile in ["builtin:backend-job", "builtin:job", "project:custom-job"] {
for key in [None, Some("resource:7".to_string())] {
let mut neutral = request().to_job_request();
neutral.profile = profile.into();
neutral.serialization_key = key;
neutral.limits = job::JobLimits {
max_concurrent_jobs: 3,
timeout_seconds: 17,
max_result_bytes: 777,
max_attempts: 1,
};
let backend = BackendJobRequest::from_job_request(neutral.clone());
backend.validate().unwrap();
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.grants.is_empty());
assert!(backend.source_worker.is_none());
assert!(backend.notification_target.is_none());
let encoded = serde_json::to_value(&backend).unwrap();
for field in ["grants", "source_worker", "notification_target"] {
assert!(encoded.get(field).is_none());
}
assert_eq!(encoded["profile"], profile);
}
}
// Conversion is not validation and must not normalize an invalid request.
let mut neutral = request().to_job_request();
neutral.limits.timeout_seconds = 0;
neutral.profile = "inherit".into();
let backend = BackendJobRequest::from_job_request(neutral.clone());
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.validate().is_err());
}
#[test]
fn reservation_and_acceptance_outcomes_expose_durable_job_and_attempt_not_host_lifecycle() {
let mut reservation = reservation();
let encoded_before = serde_json::to_value(&reservation).unwrap();
assert_eq!(
reservation.outcome(),
job::JobOutcome {
job_id: reservation.job.request.job_id.clone(),
input_revision: "7".into(),
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
attempt: 2,
state: job::JobState::Pending,
attempt_state: job::JobAttemptState::Reserved,
result: None,
failure_category: None,
failure_detail: None,
}
);
assert_eq!(serde_json::to_value(&reservation).unwrap(), encoded_before);
for (state, attempt_state) in [
(
BackendJobState::Pending,
BackendJobAttemptState::Dispatching,
),
(BackendJobState::Pending, BackendJobAttemptState::Dispatched),
(BackendJobState::Failed, BackendJobAttemptState::Failed),
(BackendJobState::Unknown, BackendJobAttemptState::Unknown),
] {
reservation.job.state = state;
reservation.attempt.state = attempt_state;
reservation.job.failure_category = Some("job-category".into());
reservation.job.failure_detail = Some("job-detail".into());
reservation.attempt.failure_category = Some("attempt-category".into());
let outcome = reservation.outcome();
assert_eq!(outcome.state, state.into());
assert_eq!(outcome.attempt_state, attempt_state.into());
assert_eq!(outcome.failure_category.as_deref(), Some("job-category"));
assert_eq!(outcome.failure_detail.as_deref(), Some("job-detail"));
assert!(outcome.result.is_none());
let accepted = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: true,
};
assert_eq!(accepted.outcome(), outcome);
}
reservation.job.state = BackendJobState::Completed;
reservation.attempt.state = BackendJobAttemptState::Completed;
reservation.job.result = Some(serde_json::json!({"ok": true}));
reservation.job.failure_category = None;
reservation.job.failure_detail = None;
reservation.attempt.worker_cleanup_state = Some(BackendJobWorkerCleanupState::Failed);
reservation.attempt.worker_cleanup_failure_detail =
Some("cleanup is not model failure".into());
reservation.replayed = true;
reservation.resource_reused = true;
let acceptance = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: false,
};
let outcome = acceptance.outcome();
assert_eq!(outcome, reservation.outcome());
assert_eq!(outcome.state, job::JobState::Completed);
assert_eq!(outcome.result, Some(serde_json::json!({"ok": true})));
assert!(outcome.failure_category.is_none());
assert!(outcome.failure_detail.is_none());
let encoded = serde_json::to_value(outcome).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 9);
for field in [
"workspace_id",
"worker",
"runtime_run_id",
"request",
"replayed",
"resource_reused",
"worker_cleanup_state",
] {
assert!(encoded.get(field).is_none());
}
}
#[test]
fn extraction_preserves_backend_wire_fingerprint_and_worker_envelope() {
let request = request();
let encoded = serde_json::to_string(&request).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
);
assert_eq!(
request.fingerprint().unwrap(),
"sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60"
);
let neutral = request.to_job_request();
neutral.validate().unwrap();
assert_ne!(
request.fingerprint().unwrap(),
neutral.fingerprint().unwrap()
);
assert_eq!(
serde_json::to_value(&request.limits).unwrap(),
serde_json::to_value(&neutral.limits).unwrap()
);
assert_eq!(
request.worker_input("attempt-1").unwrap(),
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
);
}
#[test]
fn shared_result_contract_preserves_backend_wire_and_errors() {
let submission = BackendJobResultSubmission {
job_id: "job-1".into(),
attempt_id: "job-1:attempt:1".into(),
input_revision: "7".into(),
result: serde_json::json!({"ok": true}),
};
let encoded = serde_json::to_string(&submission).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"#
);
let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap();
assert_eq!(neutral, submission);
assert_eq!(
result_digest(&submission.result, 128).unwrap(),
job::result_digest(&submission.result, 128).unwrap()
);
assert!(
matches!(result_digest(&submission.result, 4), Err(Error::InvalidInput(detail)) if detail == "Backend Job result exceeds 4 bytes")
);
let mut request = request();
request.job_id = "".into();
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job job_id must be non-empty and trimmed")
);
request.job_id = "job-1".into();
request.limits.timeout_seconds = 0;
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job timeout_seconds must be in 1..=600")
);
// Preserve domain-before-input/limits error ordering as well.
request.grants.subjektiv_consolidation = Some(SubjektivConsolidationGrant {
subject_id: "".into(),
candidate_ids: vec![],
});
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job grant subject_id must be non-empty and trimmed")
);
}
#[test]
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
let request = request();
+5 -1
View File
@@ -140,7 +140,11 @@ pub(crate) fn validate_workspace_config_candidate_projections(
};
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
if has_provider("builtin:profile") {
crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?;
crate::profile_settings::project_profiles_from_evaluation(
workspace_id,
&state,
&candidate.evaluation,
)?;
}
if has_provider("builtin:runtime") {
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
File diff suppressed because it is too large Load Diff
+160 -239
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::path::Path;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use config_source::ConfigSchemaContribution;
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::Deserialize;
use server_api::{
@@ -21,24 +21,19 @@ use crate::config_source::{
use crate::store::WorkspaceRecord;
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
profile = {
default_profile = String default "builtin:companion";
entries = [...{
selector = String;
source = String;
label = String default "";
description = String default "";
}] default [];
};
}"#;
const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl");
#[derive(Debug, Default)]
pub struct ProfileConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE)
ConfigSchemaContribution::new("builtin:profile", "profile", "2", PROFILE_SCHEMA_SOURCE)
.map(|schema| {
schema.with_authoring_source(include_str!(
"../../../resources/config-schema/profile-authoring.dcdl"
))
})
.map_err(|error| Error::Config(error.to_string()))
}
}
@@ -59,7 +54,7 @@ struct VirtualProfileSection {
#[serde(deny_unknown_fields)]
struct VirtualProfileEntry {
selector: String,
source: String,
profile: serde_json::Value,
label: String,
description: String,
}
@@ -68,7 +63,6 @@ struct VirtualProfileEntry {
pub struct ProfileConfigProjection {
pub settings: ProfileSettingsResponse,
entries: BTreeMap<String, VirtualProfileEntry>,
sources: BTreeMap<String, String>,
}
pub fn project_profiles_from_workspace_config(
@@ -96,23 +90,42 @@ pub fn project_profiles_from_workspace_config(
"Profile projection digest mismatch for Workspace {workspace_id}"
)));
}
project_profiles_from_evaluation(workspace_id, state, &evaluation)
}
pub(crate) fn project_profiles_from_evaluation(
workspace_id: &str,
state: &WorkspaceConfigState,
evaluation: &config_source::EvaluationResult,
) -> Result<ProfileConfigProjection> {
if evaluation.projection_digest != state.projection_digest {
return Err(Error::Config(
"Profile evaluation does not match Workspace config state".into(),
));
}
let projected = evaluation.projections.first().ok_or_else(|| {
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
})?;
if projected
.data_json
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.is_some_and(|entries| entries.iter().any(|entry| entry.get("source").is_some()))
{
return Err(profile_validation_error(
"profile_source_registration_removed",
"profile.entries.source is no longer supported: replace source = \"profiles/name.dcdl\" with profile = import \"./profiles/name.dcdl\" (relative to the containing config file). Saved config is not rewritten.",
));
}
let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone())
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile));
let mut entries = BTreeMap::new();
let sources = state
.snapshot
.entries
.iter()
.filter(|(_, entry)| entry.content_type == ConfigContentType::Decodal)
.map(|(path, entry)| (path.as_str().to_string(), entry.content.clone()))
.collect::<BTreeMap<_, _>>();
let mut source_summaries = Vec::new();
for entry in config.profile.entries {
if !entry.selector.starts_with("project:") {
if !entry.selector.starts_with("project:")
|| entry.selector.trim_start_matches("project:").is_empty()
{
return Err(profile_validation_error(
"profile_selector_invalid",
"Workspace Profile selectors must use project:*",
@@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config(
"Workspace Profile selectors must be unique",
));
}
let source_path = VirtualPath::parse(&entry.source).map_err(|error| {
profile_validation_error("profile_source_path_invalid", &error.to_string())
})?;
let source_entry = state.snapshot.get(&source_path).ok_or_else(|| {
profile_validation_error(
"profile_source_missing",
&format!(
"Profile source {:?} is missing from the active config revision",
entry.source
),
)
})?;
if source_entry.content_type != ConfigContentType::Decodal {
return Err(profile_validation_error(
"profile_source_type_invalid",
"Profile sources must use Decodal content",
));
}
let archive_source = entry.source.clone();
let label = if entry.label.is_empty() {
entry.selector.trim_start_matches("project:").to_string()
} else {
entry.label.clone()
};
resolve_profile_artifact_value(
evaluate_profile_source(&state.snapshot, &source_path)?,
entry.profile.clone(),
ProfileSource::Archive {
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
source: archive_source.clone(),
source: entry.selector.clone(),
},
Path::new("/"),
"workspace-config-validation",
)
.map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?;
.map_err(|error| profile_validation_error("profile_value_invalid", &error.to_string()))?;
profiles.push(WorkspaceProfileSummary {
profile_id: entry.selector.clone(),
selector: entry.selector.clone(),
label,
source_kind: "project".to_string(),
profile_source_id: Some(entry.source.clone()),
profile_source_id: Some(entry.selector.clone()),
description: (!entry.description.is_empty()).then(|| entry.description.clone()),
editable: false,
is_default: config.profile.default_profile == entry.selector,
diagnostics: Vec::new(),
});
let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes");
source_summaries.push(WorkspaceProfileSourceSummary {
profile_source_id: entry.source.clone(),
display_path: entry.source.clone(),
kind: "virtual_config".to_string(),
content_type: "decodal".to_string(),
content_digest: source_entry.content_digest.clone(),
profile_source_id: entry.selector.clone(),
display_path: format!("profile.entries[{}].profile", source_summaries.len()),
kind: "evaluated_config".to_string(),
content_type: "application/json".to_string(),
content_digest: config_source::digest_bytes(&value_bytes),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: state.snapshot.revision.to_string(),
size_bytes: source_entry.content.len() as u64,
size_bytes: value_bytes.len() as u64,
diagnostics: Vec::new(),
});
entries.insert(entry.selector.clone(), entry);
@@ -198,43 +193,13 @@ pub fn project_profiles_from_workspace_config(
registry_revision: format!("config:{}", state.snapshot.revision),
config_revision: Some(state.snapshot.revision),
tree_digest: Some(state.snapshot.digest.clone()),
projection_digest: Some(evaluation.projection_digest),
projection_digest: Some(evaluation.projection_digest.clone()),
default_profile: Some(config.profile.default_profile),
profiles,
sources: source_summaries,
diagnostics: Vec::new(),
},
entries,
sources,
})
}
fn evaluate_profile_source(
snapshot: &config_source::ConfigTreeSnapshot,
source_path: &VirtualPath,
) -> Result<serde_json::Value> {
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![source_path.clone()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
config_source::WorkspaceConfigSchemaBundle::empty(),
);
let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.map_err(|diagnostics| {
profile_validation_error(
"profile_source_invalid",
&serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "Profile source evaluation failed".to_string()),
)
})?;
evaluation
.projections
.into_iter()
.next()
.map(|projection| projection.data_json)
.ok_or_else(|| {
profile_validation_error("profile_source_invalid", "Profile source has no projection")
})
}
@@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
selector: &str,
prompt_projection: &worker::WorkspacePromptProjection,
) -> Result<Option<ConfigBundle>> {
if projection.settings.workspace_id != workspace_id
|| projection.settings.config_revision != Some(state.snapshot.revision)
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|| projection.settings.projection_digest.as_deref()
!= Some(state.projection_digest.as_str())
{
return Err(Error::Config(
"Profile projection does not match Workspace config state".into(),
));
}
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
let prompt_catalog = prompt_projection.catalog().clone();
let profile_selector =
@@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
))
})?;
let archive = match projection.entries.get(selector) {
Some(entry) => Some(build_virtual_profile_archive(
selector,
entry,
&projection.sources,
state,
)?),
Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?),
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
};
let bundle_id = virtual_profile_bundle_id(
@@ -492,19 +462,24 @@ pub(crate) fn resolve_profile_manifest_from_config_bundle(
fn build_virtual_profile_archive(
selector: &str,
entry: &VirtualProfileEntry,
all_sources: &BTreeMap<String, String>,
state: &WorkspaceConfigState,
) -> Result<ProfileSourceArchive> {
let mut closure = BTreeMap::new();
let mut imports = BTreeMap::new();
collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?;
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-config-profile-r{}", state.snapshot.revision),
entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]),
imports,
sources: closure,
})
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
let builtin_sources = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.builtin_import_sources(&state.contract)
.map_err(|diagnostics| {
profile_validation_error(
"profile_builtin_snapshot_invalid",
&serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "builtin import snapshot failed".to_string()),
)
})?;
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
format!("workspace-config-profile-r{}", state.snapshot.revision),
selector.to_string(),
entry.profile.clone(),
builtin_sources,
)
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
}
pub fn workspace_metadata_settings(
@@ -576,124 +551,6 @@ fn profile_validation_error(code: impl Into<String>, message: impl Into<String>)
}
}
fn collect_profile_import_closure(
current_path: &str,
all_sources: &BTreeMap<String, String>,
closure_sources: &mut BTreeMap<String, String>,
imports: &mut BTreeMap<String, String>,
) -> Result<()> {
if closure_sources.contains_key(current_path) {
return Ok(());
}
let content = all_sources.get(current_path).ok_or_else(|| {
profile_validation_error(
"profile_source_import_missing",
&format!("Profile source import closure is missing {current_path}"),
)
})?;
closure_sources.insert(current_path.to_string(), content.clone());
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if !all_sources.contains_key(&target) {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
}
Ok(())
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
Ok(normalized)
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir => {
if !normalized.pop() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import escapes the virtual tree",
));
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be relative",
));
}
}
}
Ok(normalized.to_string_lossy().replace('\\', "/"))
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
pub fn selector_for_builtin_candidate(
id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> {
@@ -711,6 +568,7 @@ pub fn selector_for_builtin_candidate(
#[cfg(test)]
mod tests {
use super::*;
use config_source::{ConfigContentType, VirtualPath};
#[test]
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
@@ -779,7 +637,7 @@ mod tests {
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -819,18 +677,18 @@ mod tests {
.entrypoints
.get("project:alpha")
.map(String::as_str),
Some("profiles/alpha.dcdl")
Some("profiles/evaluated.json")
);
assert_eq!(archive.reference.source_graph.source_count, 1);
}
#[test]
fn virtual_config_projection_preserves_import_closure() {
fn virtual_config_projection_materializes_import_closure() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -857,8 +715,8 @@ mod tests {
.unwrap()
.unwrap();
let archive = bundle.profile_source_archive.unwrap();
assert_eq!(archive.reference.source_graph.source_count, 2);
assert_eq!(archive.reference.source_graph.import_count, 1);
assert_eq!(archive.reference.source_graph.source_count, 1);
assert_eq!(archive.reference.source_graph.import_count, 0);
}
#[test]
@@ -955,8 +813,8 @@ mod tests {
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [
{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; },
{ selector = "project:beta"; source = "profiles/beta.dcdl"; },
{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; },
{ selector = "project:beta"; profile = import "./profiles/beta.dcdl"; },
]; }; }"#,
)
.unwrap(),
@@ -1002,20 +860,83 @@ mod tests {
}
#[test]
fn virtual_config_projection_rejects_missing_profile_source() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#,
fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
).unwrap()]).unwrap();
let environment = config_source::SnapshotEnvironment::new(snapshot.clone())
.with_schema_bundle(bundle.clone());
for (body, token, label) in [
(
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
"sel",
"selector",
),
(
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
"pro",
"profile",
),
(
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
"wor",
"worker",
),
(
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
"mo",
"mode",
),
(
"{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema",
"ti",
"ticket",
),
] {
let offset = body.rfind(token).unwrap() + token.len();
let completion = environment
.complete_config(
&VirtualPath::parse("main.dcdl").unwrap(),
body,
offset,
true,
)
.unwrap(),
]);
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
.unwrap()
.unwrap();
assert!(
error
.to_string()
.contains("missing from the active config revision")
completion.items.iter().any(|item| item.label == label),
"{label}: {completion:?}"
);
}
for invalid in [
r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#,
] {
assert!(
!environment
.analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid))
.is_empty()
);
}
let evaluation = environment
.evaluate_contract(&config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
))
.unwrap();
assert_eq!(
evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&serde_json::json!({}))
);
}
}
File diff suppressed because it is too large Load Diff
@@ -151,23 +151,7 @@ fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> Result<Vec<String>> {
// Reserve the bounded subject and Host surface envelope. Each candidate ID
// appears twice (input claims and verified outcome); account for JSON escaping,
// not raw UTF-8 bytes. The fixed per-item allowance covers action and framing.
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| Error::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
subjektiv::job::bounded_candidate_batch(ids, max_result_bytes).map_err(Error::from)
}
pub(super) fn dispatch(
@@ -234,25 +218,6 @@ pub(super) fn dispatch(
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
pub(super) fn validate_result(
api: &WorkspaceApi,
request: &BackendJobRequest,
@@ -262,39 +227,18 @@ pub(super) fn validate_result(
let Some(grant) = &request.grants.subjektiv_consolidation else {
return Ok(());
};
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| Error::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != grant.subject_id || result.candidate_ids != grant.candidate_ids {
return Err(Error::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
for candidate_id in &grant.candidate_ids {
if store
.staging_resolution(&grant.subject_id, candidate_id)
.map_err(|e| Error::Store(e.to_string()))?
.is_none()
{
return Err(Error::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
subjektiv::job::validate_result(
&store,
&grant.subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
&grant.candidate_ids,
&request.job_id,
&attempt.attempt_id,
value,
)
.map_err(|e| Error::InvalidInput(format!("consolidation surface result mismatch: {e}")))
.map_err(Error::from)
}
/// Persist actual candidate dispositions alongside the separate surface outcome.
@@ -313,28 +257,8 @@ pub(super) fn with_dispositions(
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
let mut dispositions = Vec::new();
for id in &grant.candidate_ids {
let resolution = store
.staging_resolution(&grant.subject_id, id)
.map_err(|e| Error::Store(e.to_string()))?
.ok_or_else(|| {
Error::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
// Full reasons, affected revisions and source edges remain in the existing
// immutable receipt store. Duplicating them makes a valid bounded batch's
// completion unbounded; this result is its verified compact projection.
dispositions.push(serde_json::json!({ "candidate_id": id, "action": resolution.action }));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| Error::InvalidInput("consolidation result must be an object".into()))?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
subjektiv::job::with_dispositions(&store, &grant.subject_id, &grant.candidate_ids, value)
.map_err(Error::from)
}
#[cfg(test)]
@@ -454,7 +454,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
changes: vec![server_api::ConfigTreeChange::Update {
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; source = \"profiles/job-recipe.dcdl\"; }]; }; }".into(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; profile = import \"./profiles/job-recipe.dcdl\"; }]; }; }".into(),
}, server_api::ConfigTreeChange::Create {
path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal,
content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(),
@@ -0,0 +1,859 @@
use super::*;
use crate::profile_settings::{
build_virtual_profile_config_bundle, project_profiles_from_workspace_config,
};
const PROFILE: &str = r#"{
slug = "alpha";
worker = { mode = "wip"; };
model = { ref = "codex-oauth/gpt-5.6-sol"; };
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
}"#;
fn request(
api: &WorkspaceApi,
source: &str,
extra: Vec<config_source::ConfigTreeChange>,
) -> ConfigCommitRequest {
let state = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let main = state
.snapshot
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
.unwrap();
let mut changes = vec![config_source::ConfigTreeChange::Update {
path: main.path.clone(),
expected_digest: main.content_digest.clone(),
content: source.into(),
}];
changes.extend(extra);
ConfigCommitRequest {
base_revision: state.snapshot.revision,
base_digest: state.snapshot.digest,
entrypoints: state.contract.entrypoints,
changes,
}
}
fn source(value: &str) -> String {
format!(
r#"{{ profile = {{ default_profile = "project:alpha"; entries = [{{ selector = "project:alpha"; label = "Alpha"; description = "Value recipe"; profile = {value}; }}]; }}; }}"#
)
}
#[tokio::test]
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let app = build_router(api.clone());
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let commit = format!("{tree}/commit");
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
let saved = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(
serde_json::to_value(request(
&api,
&source(r#"import "$builtin/profiles/companion.dcdl""#),
vec![config_source::ConfigTreeChange::Create {
// An ordinary Workspace file at the matching suffix must not rescue
// an unknown builtin import.
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
))
.unwrap(),
),
&token,
StatusCode::CREATED,
)
.await;
assert_eq!(
saved["snapshot"]["revision"].as_u64().unwrap(),
initial["snapshot"]["revision"].as_u64().unwrap() + 1
);
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
assert_eq!(listed, saved);
let entries = listed["snapshot"]["entries"].as_object().unwrap();
assert!(entries.contains_key("profiles/private.dcdl"));
assert!(
entries
.keys()
.all(|key| key != "$builtin" && !key.starts_with("$builtin/"))
);
let shadow = get_json_authenticated(
app.clone(),
&format!("{tree}/entries/profiles%2Fprivate.dcdl"),
&token,
)
.await;
assert_eq!(shadow, entries["profiles/private.dcdl"]);
assert!(
shadow["content"]
.as_str()
.unwrap()
.contains("slug = \"alpha\"")
);
let active = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
let base = json!({
"base_revision": listed["snapshot"]["revision"],
"base_digest": listed["snapshot"]["digest"],
"entrypoints": listed["contract"]["entrypoints"],
});
let reserved = "$builtin/profiles/companion.dcdl";
for change in [
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
] {
let mut body = base.clone();
body["changes"] = json!([change]);
let error = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(body),
&token,
StatusCode::BAD_REQUEST,
)
.await;
assert!(
error["message"]
.as_str()
.unwrap()
.contains("builtin namespace is read-only"),
"{error}"
);
assert_eq!(
get_json_authenticated(app.clone(), &tree, &token).await,
listed
);
}
let mut body = base;
body["changes"] = json!([{
"kind": "update", "path": "main.dcdl", "expected_digest": main_digest,
"content": source(r#"import "$builtin/profiles/private.dcdl""#),
}]);
let error = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(body),
&token,
StatusCode::BAD_REQUEST,
)
.await;
let message = error["message"].as_str().unwrap();
assert!(
message.contains(
"unknown or non-public read-only builtin source: $builtin/profiles/private.dcdl"
),
"{error}"
);
assert!(message.contains("\"kind\":\"import\""), "{error}");
assert_eq!(get_json_authenticated(app, &tree, &token).await, listed);
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
active
);
}
#[tokio::test]
async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let recipe = r#"(import "$builtin/profiles/companion.dcdl") // {
worker = { mode = "wip"; };
feature = {
task = { enabled = true; };
ticket = { enabled = false; };
workspace_config = { enabled = true; };
};
}"#;
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(
&api,
&source(r#"import "./recipes/custom.dcdl""#),
vec![
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/custom.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: recipe.into(),
},
// Builtin relative imports must not be shadowed by Workspace files.
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("profiles/base.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: "{ slug = \"workspace-shadow\"; }".into(),
},
],
),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone());
let builtin_sources = environment.builtin_import_sources(&state.contract).unwrap();
assert_eq!(
builtin_sources
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
vec![
"$builtin/profiles/base.dcdl",
"$builtin/profiles/companion.dcdl",
]
);
assert_eq!(
archive.reference.source_graph.source_count,
builtin_sources.len() + 1
);
assert_eq!(archive.reference.source_graph.import_count, 0);
assert_eq!(
verified.manifest().entrypoints["project:alpha"],
"profiles/evaluated.json"
);
for (key, bytes) in &builtin_sources {
let source = verified
.manifest()
.sources
.iter()
.find(|source| &source.source_key == key)
.unwrap();
assert_eq!(source.path, *key);
assert_eq!(source.kind, "decodal");
assert_eq!(
source.digest,
worker_runtime::profile_archive::sha256_hex(bytes.as_bytes())
);
assert_eq!(source.size_bytes, bytes.len() as u64);
}
assert!(
verified
.manifest()
.sources
.iter()
.any(|source| source.path == "profiles/evaluated.json" && source.kind == "json")
);
// Resolve using the Runtime archive consumer, without access to the config tree.
let resolved = verified
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"builtin-value-worker",
)
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(!resolved.feature.ticket.enabled);
assert!(resolved.feature.workspace_config.enabled);
let evaluation = environment.evaluate_contract(&state.contract).unwrap();
assert_eq!(
resolved.model.ref_.as_deref(),
evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile/model/ref")
.and_then(serde_json::Value::as_str)
);
let persisted = manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap();
let old_bundle_digest = bundle.metadata.digest.clone();
let updated = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(PROFILE), vec![]),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &updated).unwrap();
let updated_bundle = build_virtual_profile_config_bundle(
&projection,
&updated,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
assert_ne!(old_bundle_digest, updated_bundle.metadata.digest);
assert_eq!(
updated_bundle
.profile_source_archive
.as_ref()
.unwrap()
.reference
.source_graph
.source_count,
1
);
// Editing config changes future snapshots, not a sealed bundle or persisted Worker.
assert_eq!(bundle.metadata.digest, old_bundle_digest);
assert_eq!(
serde_json::to_value(manifest::read_persisted_worker_manifest_snapshot(persisted).unwrap())
.unwrap(),
serde_json::to_value(resolved).unwrap()
);
}
#[tokio::test]
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let forms = [
PROFILE.to_string(),
r#"import "./recipes/alpha.dcdl""#.into(),
r#"(import "./recipes/base.dcdl") // { worker = { mode = "wip"; }; feature.task.enabled = true; }"#.into(),
];
let mut manifests = Vec::new();
let mut saved_manifest = None;
for (index, form) in forms.iter().enumerate() {
let extra = if index == 0 {
vec![
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
},
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/base.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
},
]
} else {
vec![]
};
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(form), extra),
)
.unwrap();
let loaded = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_eq!(loaded, state);
// The Browser receives the same fingerprint-bound authoring metadata through REST.
let wire = workspace_config_state_to_api(loaded.clone());
let decoded: config_source::WorkspaceConfigSchemaBundle =
serde_json::from_value(serde_json::to_value(wire.contract.schema_bundle).unwrap())
.unwrap();
decoded.validate().unwrap();
assert_eq!(decoded, loaded.contract.schema_bundle);
assert!(
decoded
.contributions
.iter()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap()
.authoring_source
.is_some()
);
let projection =
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &loaded).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
projection.settings.config_revision,
Some(state.snapshot.revision)
);
let selected = projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap();
assert_eq!(selected.label, "Alpha");
assert_eq!(selected.description.as_deref(), Some("Value recipe"));
assert!(selected.is_default);
assert_eq!(selected.profile_source_id.as_deref(), Some("project:alpha"));
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"2026-01-01T00:00:00Z",
"project:alpha",
)
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
assert_eq!(
bundle.metadata.revision,
state.snapshot.revision.to_string()
);
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
let resolved = verified
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"value-worker",
)
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(!resolved.feature.ticket.enabled);
assert!(!resolved.feature.ticket.authoring);
assert!(!resolved.feature.sub_worker.enabled);
assert_eq!(archive.reference.source_graph.import_count, 0);
let evaluation = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.evaluate_contract(&state.contract)
.unwrap();
let value = evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile")
.unwrap();
assert!(value.is_object());
assert!(
value.get("engine").is_none(),
"omission must remain omission in the Profile value"
);
let mut snapshot = serde_json::to_value(&resolved).unwrap();
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
manifests.push(snapshot);
if index == 0 {
saved_manifest =
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap());
}
if index == 2 {
let mut other_state = state.clone();
other_state.snapshot.revision += 1;
assert!(
build_virtual_profile_config_bundle(
&projection,
&other_state,
TEST_WORKSPACE_ID,
"created",
"project:alpha"
)
.unwrap_err()
.to_string()
.contains("does not match")
);
}
}
assert_eq!(manifests[0], manifests[1]);
assert_eq!(manifests[1], manifests[2]);
// Saving a new recipe is not an operation on existing Workers or their persisted Manifest.
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(
&api,
&source(
&PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
),
vec![],
),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let fresh = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
&bundle,
"project:alpha",
)
.unwrap();
assert_eq!(fresh.worker.mode, manifest::WorkerMode::Tools);
assert!(!fresh.feature.task.enabled);
let restored =
manifest::read_persisted_worker_manifest_snapshot(saved_manifest.unwrap()).unwrap();
assert_eq!(restored.worker.mode, manifest::WorkerMode::Wip);
assert!(restored.feature.task.enabled);
}
#[tokio::test]
async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let scalar = format!(
r#"{PROFILE} // {{ scope = "workspace_read"; delegation_scope = "workspace_write"; }}"#
);
let table = format!(
r#"{PROFILE} // {{ scope = {{ intent = "workspace_read"; }}; delegation_scope = {{ intent = "workspace_write"; }}; }}"#
);
let mut scopes = Vec::new();
for (index, form) in [scalar.clone(), table, r#"import "./scoped.dcdl""#.into()]
.iter()
.enumerate()
{
let extra = if index == 0 {
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("scoped.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: scalar.clone(),
}]
} else {
vec![]
};
let authored = format!("{} as WorkspaceConfigSchema", source(form));
let state =
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &authored, extra))
.unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.with_schema_bundle(state.contract.schema_bundle.clone());
assert!(
environment
.analyze(
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
None
)
.is_empty(),
"{authored}"
);
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let resolved = bundle
.profile_source_archive
.as_ref()
.unwrap()
.verify()
.unwrap()
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"scoped-worker",
)
.unwrap();
assert_eq!(resolved.scope.allow.len(), 1);
assert_eq!(resolved.delegation_scope.allow.len(), 1);
let scope = serde_json::to_value(&resolved.scope).unwrap();
let delegation = serde_json::to_value(&resolved.delegation_scope).unwrap();
assert_eq!(scope["allow"][0]["target"], "/runtime-worker");
assert_eq!(scope["allow"][0]["permission"], "read");
assert_eq!(delegation["allow"][0]["target"], "/runtime-worker");
assert_eq!(delegation["allow"][0]["permission"], "write");
scopes.push((scope, delegation));
}
assert_eq!(scopes[0], scopes[1]);
assert_eq!(scopes[1], scopes[2]);
}
fn profile_fixture_decodal(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(key, value)| format!("{key} = {};", profile_fixture_decodal(value)))
.collect::<Vec<_>>()
.join(" ")
),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(profile_fixture_decodal)
.collect::<Vec<_>>()
.join(", ")
),
serde_json::Value::Null => panic!("fixture omission must not be represented as null"),
value => value.to_string(),
}
}
#[tokio::test]
async fn value_profiles_authoring_corpus_preserves_effective_runtime_settings() {
let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!(
"../../../../../resources/config-schema/profile-authoring-values.json"
))
.unwrap();
// The canonical token fixture must exercise every serializable setting,
// so adding a field to CompactionConfigPartial cannot silently outgrow the
// editor-shape coverage. Aliases/ratio helpers have separate corpus cases.
let default = serde_json::to_value(manifest::CompactionConfigPartial::default()).unwrap();
let canonical = cases
.iter()
.find(|case| case["name"] == "canonical_compaction_tokens")
.unwrap();
assert_eq!(
default
.as_object()
.unwrap()
.keys()
.collect::<std::collections::BTreeSet<_>>(),
canonical["profile"]["compaction"]
.as_object()
.unwrap()
.keys()
.filter(|name| name.as_str() != "kind")
.collect()
);
for case in cases {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let recipe = profile_fixture_decodal(&case["profile"]);
for (index, (form, patched)) in [
(recipe.clone(), false),
(r#"import "./recipe.dcdl""#.into(), false),
(
r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(),
true,
),
]
.iter()
.enumerate()
{
let extra = if index == 0 {
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipe.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: recipe.clone(),
}]
} else {
vec![]
};
let authored = format!("{} as WorkspaceConfigSchema", source(form));
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &authored, extra),
)
.unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.with_schema_bundle(state.contract.schema_bundle.clone());
let diagnostics = environment.analyze(
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
None,
);
assert!(diagnostics.is_empty(), "{}: {diagnostics:?}", case["name"]);
let mut expected = case["profile"].clone();
if *patched {
expected["description"] = serde_json::json!("patched");
}
let evaluated = environment.evaluate_contract(&state.contract).unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&expected)
);
let projection =
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let resolved = bundle
.profile_source_archive
.as_ref()
.unwrap()
.verify()
.unwrap()
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"corpus-worker",
)
.unwrap();
let manifest = serde_json::to_value(resolved).unwrap();
for (pointer, value) in case["manifest_expect"].as_object().unwrap() {
assert_eq!(
manifest.pointer(pointer),
Some(value),
"{}: {pointer}",
case["name"]
);
}
}
}
}
#[tokio::test]
async fn value_profiles_invalid_saves_preserve_the_active_config() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let before = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(PROFILE), vec![]),
)
.unwrap();
let invalid = [
(source("42"), ""),
(source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"),
(source(r#"{ worker = { name = "runtime-only"; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = 42; }"#), "profile_value_invalid"),
(source(r#"{ scope = "not-an-intent"; }"#), "profile_value_invalid"),
(source(r#"{ delegation_scope = { intent = 42; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = { intent = "workspace_read"; unknown = true; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = { intent = "workspace_read"; deny_write = 42; }; }"#), "profile_value_invalid"),
(source(r#"{ delegation_scope = { intent = "workspace_write"; symlink_policy = 42; }; }"#), "profile_value_invalid"),
(source(r#"import "./missing.dcdl""#), ""),
(source(r#"import "../outside.dcdl""#), ""),
(r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }, { selector = "project:alpha"; profile = {}; }]; }; }"#.into(), "profile_selector_duplicate"),
(r#"{ profile = { default_profile = "project:unknown"; }; }"#.into(), "unknown_default_profile"),
(r#"{ profile = { entries = [{ selector = "project:"; profile = {}; }]; }; }"#.into(), "profile_selector_invalid"),
(r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#.into(), ""),
];
for (source, code) in invalid {
let error =
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &source, vec![]))
.unwrap_err();
assert!(error.to_string().contains(code), "{error}");
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
before
);
}
}
#[tokio::test]
async fn value_profiles_legacy_source_config_requires_explicit_lossless_migration() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let mut contributions = api.config_schema_registry.compose().unwrap().contributions;
let profile_schema = contributions
.iter_mut()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap();
*profile_schema = config_source::ConfigSchemaContribution::new("builtin:profile", "profile", "1", r#"{
profile = { default_profile = String default "builtin:companion";
entries = [...{ selector = String; source = String; label = String default ""; description = String default ""; }] default [];
};
}"#).unwrap();
let old_schema = config_source::WorkspaceConfigSchemaBundle::compose(contributions).unwrap();
let old_source = r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; label = "Alpha"; source = "recipes/alpha.dcdl"; }]; }; }"#;
let candidate = api
.config_store
.evaluate_workspace_config_candidate_with_schema(
TEST_WORKSPACE_ID,
&request(
&api,
old_source,
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
),
old_schema,
)
.unwrap();
// Fixture simulates a previously persisted v1 schema/revision.
let legacy = api
.config_store
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
.unwrap();
let error = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &legacy).unwrap_err();
assert!(
error
.to_string()
.contains("profile_source_registration_removed")
);
assert!(error.to_string().contains("profile = import"));
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
assert!(
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, old_source, vec![]))
.is_err()
);
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
let migrated_source = old_source.replace(
r#"source = "recipes/alpha.dcdl""#,
r#"profile = import "./recipes/alpha.dcdl""#,
);
let migrated = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &migrated_source, vec![]),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &migrated).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
migrated
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()),
legacy
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap())
);
assert_eq!(
projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap()
.label,
"Alpha"
);
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ server-api = { workspace = true }
workdir = { workspace = true }
tokio-tungstenite = { workspace = true }
session-store = { workspace = true }
standalone = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }
tui = { workspace = true }
+4 -1
View File
@@ -27,6 +27,7 @@ pub(crate) enum CliCommand {
Plugin,
Mcp,
Session,
Subject,
Login,
}
@@ -46,6 +47,7 @@ impl CliCommand {
CliCommand::Plugin => "yoi plugin",
CliCommand::Mcp => "yoi mcp",
CliCommand::Session => "yoi session",
CliCommand::Subject => "yoi subject",
CliCommand::Login => "yoi login",
}
}
@@ -65,7 +67,8 @@ impl CliCommand {
| CliCommand::WorkerCleanup
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
| CliCommand::Session
| CliCommand::Subject => CliConnectionRequirement::LocalOnly,
}
}
}
+209 -2
View File
@@ -3,6 +3,7 @@ mod mcp_cli;
mod objective_cli;
mod plugin_cli;
mod session_cli;
mod subject_cli;
mod ticket_cli;
mod worker_cleanup_cli;
mod workspace_bootstrap;
@@ -41,6 +42,10 @@ enum Mode {
target: client::ResolvedTarget,
},
Session(session_cli::SessionCli),
Subject {
cli: subject_cli::SubjectCli,
target: Box<dyn Target>,
},
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket {
cli: ticket_cli::TicketCli,
@@ -171,6 +176,26 @@ async fn run(mode: Mode) -> ExitCode {
}
}
}
Mode::Subject { cli, target } => {
let result = if cli == subject_cli::SubjectCli::Help {
Ok(subject_cli::HELP.to_owned())
} else {
match target.spawn_worker() {
Ok(spawn) => subject_cli::run(cli, &spawn.state_dir),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
};
match result {
Ok(output) => {
print!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi subject: {error}");
ExitCode::FAILURE
}
}
}
Mode::Session(cli) => match session_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
@@ -406,6 +431,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
LaunchMode::Spawn {
worker_name: None,
profile: None,
subject_id: None,
}
};
return Ok(Mode::Tui {
@@ -454,6 +480,21 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Objective { cli, target });
}
"subject" => {
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"yoi subject is local-only and does not accept Backend selectors".to_owned(),
));
}
if !target_selection.explicit_local {
return Err(ParseError(
"yoi subject requires explicit --local selection".to_owned(),
));
}
let cli = subject_cli::parse(&args[1..])?;
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Subject)?;
return Ok(Mode::Subject { cli, target });
}
"session" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
let session_cli = session_cli::parse_session_args(&args[1..])
@@ -828,6 +869,19 @@ fn required_option_value<'a>(
Ok(value)
}
fn set_subject_selector(selector: &mut Option<String>, value: &str) -> Result<(), ParseError> {
if selector.is_some() {
return Err(ParseError(
"--subject must not be provided more than once".to_owned(),
));
}
if value.trim().is_empty() || value.starts_with('-') {
return Err(ParseError("--subject requires a nonempty ID".to_owned()));
}
*selector = Some(value.to_owned());
Ok(())
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
@@ -837,6 +891,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
let mut worker_name = None;
let mut session = None;
let mut profile = None;
let mut subject_id = None;
let mut socket_override = None;
let mut runtime_id = None;
let mut worker_id = None;
@@ -884,6 +939,15 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
socket_override = Some(PathBuf::from(value));
i += 2;
}
"--subject" => {
let value = required_option_value(args, i, "--subject")?;
set_subject_selector(&mut subject_id, value)?;
i += 2;
}
arg if arg.starts_with("--subject=") => {
set_subject_selector(&mut subject_id, &arg["--subject=".len()..])?;
i += 1;
}
"--profile" => {
let value = args
.get(i + 1)
@@ -989,6 +1053,24 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
}
}
if subject_id.is_some() {
if session.is_some()
|| socket_override.is_some()
|| runtime_id.is_some()
|| worker_id.is_some()
{
return Err(ParseError(
"--subject can only be used for fresh local spawn, not attach or restore"
.to_owned(),
));
}
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"--subject is local-only and does not accept Backend selectors".to_owned(),
));
}
}
if worker_id.is_some() && runtime_id.is_none() {
return Err(ParseError(
"--worker-id requires --runtime-id for Runtime API attach".to_string(),
@@ -1018,12 +1100,29 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
return Err(ParseError("--socket requires --worker".to_string()));
}
let target = resolve_tui_target(
let target = if subject_id.is_some() {
// A local Subject selector must never trigger Backend Workspace discovery.
let target = resolve_connection_aware_cli_connection(
connection_resolver,
CliCommand::DefaultTui,
target_selection.explicit_local,
None,
None,
)?;
if target.kind() != TargetKind::Standalone {
return Err(ParseError(
"--subject requires a local target; use --local".to_owned(),
));
}
target
} else {
resolve_tui_target(
connection_resolver,
CliCommand::DefaultTui,
target_selection,
&workspace_root,
)?;
)?
};
if target.kind() == TargetKind::Standalone {
if runtime_id.is_some() || worker_id.is_some() {
@@ -1081,6 +1180,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
LaunchMode::Spawn {
worker_name,
profile,
subject_id,
}
} else {
if worker_name.is_some()
@@ -1912,6 +2012,7 @@ Connection-aware commands:
Console options:
--workspace <PATH> Standalone cwd or client display scope (defaults to cwd)
--profile <REF> Select the Standalone Profile recipe
--subject <ID> Select an existing local Subject for fresh spawn only
--runtime-id <ID> Backend Runtime id
--worker-id <ID> Backend Worker id; requires --runtime-id
@@ -1926,6 +2027,7 @@ Host commands:
plugin <COMMAND> Author/check/pack explicit Plugin packages
mcp <COMMAND> Inspect configured MCP servers
session <COMMAND> Inspect/prune Standalone session logs
--local subject <COMMAND> Explicitly create/list local Subjects (create <ROLE>, list)
Standalone binaries:
yoi-server Workspace Backend server/admin CLI
@@ -2618,11 +2720,115 @@ backend = "shared"
LaunchMode::Spawn {
worker_name: Some(ref name),
profile: None,
subject_id: None,
} if name == "my-local-worker"
));
assert!(target.spawn_worker().unwrap().state_dir.is_absolute());
}
#[test]
fn subject_selection_is_carried_only_to_local_fresh_spawn() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://default-backend.example",
};
for selector in [
vec!["--subject", "subject-id"],
vec!["--subject=subject-id"],
] {
let args = [
vec!["--local", "--profile", "builtin:standalone-subjektiv"],
selector,
]
.concat()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let Mode::Tui {
target,
mode: LaunchMode::Spawn { subject_id, .. },
..
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected local spawn");
};
assert_eq!(target.kind(), TargetKind::Standalone);
assert_eq!(subject_id.as_deref(), Some("subject-id"));
}
for input in [
vec!["--subject", "subject-id"],
vec![
"--backend",
"http://backend.example",
"--subject",
"subject-id",
],
vec!["--local", "--workspace-id", "ws", "--subject=id"],
vec!["--local", "--subject", ""],
vec!["--local", "--subject", " "],
vec!["--local", "--subject="],
vec!["--local", "--subject=id", "--subject=other"],
vec!["--local", "--subject=id", "--subject", "other"],
vec!["--local", "--subject=id", "--session", "session"],
vec![
"--local",
"--subject=id",
"--socket",
"socket",
"--worker",
"worker",
],
vec!["--local", "--subject=id", "--runtime-id", "runtime"],
vec!["--local", "--subject=id", "--worker-id", "worker"],
vec!["--local", "resume", "--subject=id"],
vec!["--local", "resume", "--subject", "id"],
] {
let args = input
.iter()
.map(|arg| (*arg).to_owned())
.collect::<Vec<_>>();
assert!(
parse_args_slice_with_connection_resolver(&args, &resolver).is_err(),
"{input:?}"
);
}
}
#[test]
fn subject_commands_require_explicit_local_target() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://default-backend.example",
};
for command in [vec!["create", "assistant"], vec!["list"], vec!["--help"]] {
let args = [vec!["--local", "subject"], command]
.concat()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let Mode::Subject { target, .. } =
parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected Subject mode");
};
assert_eq!(target.kind(), TargetKind::Standalone);
assert!(target.spawn_worker().unwrap().state_dir.is_absolute());
}
for input in [
vec!["subject", "list"],
vec!["--backend", "http://backend.example", "subject", "list"],
vec!["--local", "--workspace-id=ws", "subject", "list"],
vec!["--local", "subject", "list", "--backend=url"],
] {
let args = input
.iter()
.map(|arg| (*arg).to_owned())
.collect::<Vec<_>>();
assert!(
parse_args_slice_with_connection_resolver(&args, &resolver).is_err(),
"{input:?}"
);
}
}
#[test]
fn parser_rejects_removed_top_level_resume_flags() {
for (args, expected) in [
@@ -3346,6 +3552,7 @@ backend = "shared"
LaunchMode::Spawn {
worker_name,
profile,
subject_id: None,
},
workspace_root,
..
+184
View File
@@ -0,0 +1,184 @@
use std::path::Path;
use standalone::subjektiv::StandaloneSubjects;
use super::ParseError;
pub(crate) const HELP: &str = "Local Subjects (operator-managed standalone state):
yoi --local subject create <ROLE> [--behavior-md <TEXT>]
yoi --local subject list
yoi --local --subject <ID> [--profile builtin:standalone-subjektiv]
Create prints a Subject record as JSON; list prints a JSON array.
Selection is explicit and only applies to fresh local launches.
Resume restores the original Subject binding and does not accept --subject.
";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SubjectCli {
Help,
Create {
role: String,
behavior_md: Option<String>,
},
List,
}
pub(crate) fn parse(args: &[String]) -> Result<SubjectCli, ParseError> {
match args.first().map(String::as_str) {
None => Ok(SubjectCli::Help),
Some("--help" | "-h") if args.len() == 1 => Ok(SubjectCli::Help),
Some("list") if args.len() == 1 => Ok(SubjectCli::List),
Some("create") => {
let role = args
.get(1)
.filter(|role| !role.starts_with('-') && !role.trim().is_empty())
.ok_or_else(|| ParseError("subject create requires a nonempty ROLE".to_owned()))?;
let mut behavior_md = None;
let mut i = 2;
while i < args.len() {
if behavior_md.is_some() {
return Err(ParseError(
"subject create accepts --behavior-md only once".to_owned(),
));
}
match args[i].as_str() {
"--behavior-md" => {
let text = args
.get(i + 1)
.filter(|text| !text.starts_with("--"))
.ok_or_else(|| ParseError("--behavior-md requires TEXT".to_owned()))?;
behavior_md = Some(text.clone());
i += 2;
}
arg if arg.starts_with("--behavior-md=") => {
behavior_md = Some(arg.trim_start_matches("--behavior-md=").to_owned());
i += 1;
}
arg => {
return Err(ParseError(format!(
"unknown subject create argument `{arg}`"
)));
}
}
}
Ok(SubjectCli::Create {
role: role.clone(),
behavior_md,
})
}
Some(arg) => Err(ParseError(format!(
"invalid subject command or arguments `{arg}`; use `yoi --local subject --help`"
))),
}
}
pub(crate) fn run(cli: SubjectCli, state_dir: &Path) -> Result<String, Box<dyn std::error::Error>> {
if cli == SubjectCli::Help {
return Ok(HELP.to_owned());
}
let subjects = StandaloneSubjects::open(state_dir)?;
let json = match cli {
SubjectCli::Create { role, behavior_md } => {
serde_json::to_string_pretty(&subjects.create(&role, behavior_md.as_deref())?)?
}
SubjectCli::List => serde_json::to_string_pretty(&subjects.list()?)?,
SubjectCli::Help => unreachable!(),
};
Ok(format!("{json}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_owned()).collect()
}
#[test]
fn parse_explicit_create_and_list() {
assert_eq!(
parse(&args(&[
"create",
"assistant",
"--behavior-md",
"Remember decisions."
]))
.unwrap(),
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Remember decisions.".to_owned())
}
);
assert_eq!(parse(&args(&["list"])).unwrap(), SubjectCli::List);
}
#[test]
fn reject_missing_empty_duplicate_and_unknown_arguments() {
for input in [
vec!["create"],
vec!["create", " "],
vec!["create", "role", "--behavior-md"],
vec!["create", "role", "--behavior-md=x", "--behavior-md=y"],
vec!["list", "--subject=id"],
vec!["create", "role", "--backend=url"],
] {
assert!(parse(&args(&input)).is_err(), "{input:?}");
}
}
#[test]
fn create_and_list_share_only_the_selected_state_root() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
// New state roots are created privately; temporary parent directories
// need not meet the state-root permission contract.
let state_dir = root.path().join("state");
let other_state_dir = other.path().join("state");
let created: serde_json::Value = serde_json::from_str(
&run(
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Be precise.".to_owned()),
},
&state_dir,
)
.unwrap(),
)
.unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &state_dir).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([created]));
let elsewhere: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &other_state_dir).unwrap()).unwrap();
assert_eq!(elsewhere, serde_json::json!([]));
}
#[cfg(unix)]
#[test]
fn nonprivate_existing_root_requires_explicit_operator_permission_change() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(run(SubjectCli::List, root.path()).is_err());
assert_eq!(
std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777,
0o755
);
assert!(!root.path().join("subjektiv").exists());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, root.path()).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([]));
}
#[test]
fn help_does_not_open_state() {
let root = tempfile::tempdir().unwrap();
let absent = root.path().join("absent");
assert_eq!(run(SubjectCli::Help, &absent).unwrap(), HELP);
assert!(!absent.exists());
}
}
+49 -2
View File
@@ -17,7 +17,54 @@ A Profile should not contain runtime-bound fields:
Those fields depend on one run, one parent, or one machine. Putting them in a reusable Profile makes reuse unsafe.
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Decodal source archives, or explicit JSON/TOML artifacts. Decodal is the authoring syntax used by the Workspace profile editor and Backend Runtime archive path; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch should not depend on executable profile scripts.
Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Workspace Config values, or explicit JSON/TOML artifacts. Decodal is the Workspace authoring syntax; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch does not depend on executable profile scripts.
### Workspace registration and migration
Register the Profile **value**, not a source pathname:
```dcdl
{
profile = {
default_profile = "project:my-companion";
entries = [{
selector = "project:my-companion";
label = "My Companion";
description = "My reusable Worker recipe";
profile = import "./profiles/my-companion.dcdl";
}];
};
} as WorkspaceConfigSchema
```
The imported file can contain `{ worker = { mode = "wip"; }; model = { ref = "codex-oauth/gpt-5.6-sol"; }; feature = { task = { enabled = true; }; }; }`. That record can also be written directly at `entries[0].profile`. Composition uses ordinary Decodal expressions, for example `(import "./profiles/base.dcdl") // { worker = { mode = "wip"; }; }` for an explicit patch, or `&` for compatible refinement. No dedicated Profile file or `profiles/` directory is required. Imports are relative to the **containing virtual config file**, not a host filesystem path; missing imports and virtual-tree escapes are rejected.
`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. To inherit a builtin recipe, import its public value explicitly; there is no implicit builtin inheritance.
### Public builtin imports
Workspace Decodal can import embedded Profile values through the read-only `$builtin/` namespace:
```dcdl
(import "$builtin/profiles/companion.dcdl") // {
worker = { mode = "wip"; };
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
}
```
Use this expression anywhere a Profile value is accepted, including `profile.entries[*].profile` or an imported Workspace recipe. `$builtin/profiles/companion.dcdl` is a source path, not the launch selector `builtin:companion`. Public paths under `$builtin/profiles/` are `base.dcdl`, `default.dcdl`, `standalone.dcdl`, `standalone-subjektiv.dcdl`, `standalone-subjektiv-consolidation.dcdl`, `coder.dcdl`, `companion.dcdl`, `intake.dcdl`, `reviewer.dcdl`, `orchestrator.dcdl`, `job.dcdl`, `backend-job.dcdl`, `memory-consolidation.dcdl`, and `subjektiv-memory-consolidation.dcdl`. This is an explicit embedded catalog, not arbitrary resource or filesystem access. Unknown paths, namespace escapes, host paths and network URLs are errors.
Relative imports **inside a builtin source** resolve against its builtin virtual directory: companion's `./base.dcdl` means `$builtin/profiles/base.dcdl`, never the Workspace's `profiles/base.dcdl`. Relative imports in Workspace files continue to use their containing Workspace virtual directory. Builtin values obey ordinary Decodal composition: `//` applies an explicit patch and `&` refines compatible values (conflicting explicit values remain errors). Importing a builtin does not add a special merge or fallback mechanism.
Builtin dependencies share the Workspace snapshot budgets: at most 256 entries across Workspace entries and distinct observed builtin sources, 256 KiB per source, and 4 MiB of total source bytes. Unused Workspace entries still count; repeated imports of the same builtin count once. The observed import graph must be acyclic and at most 32 edges deep, **including the synthetic evaluation wrapper's import of the entrypoint**. These guards apply before the loader returns sources to Decodal, including imports in lazy object fields; builtins do not bypass them. Launch archives retain their separate transport limits (64 sources, 256 KiB per source, 1 MiB total source bytes).
The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config.
The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched.
`scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values.
Extraction `reasoning` in both `feature.memory` and `feature.subjektiv`, like `engine.reasoning`, accepts either a string effort or a signed integer budget. These union values are also opaque to editor constraints and semantically checked on save. Compaction authoring covers the complete token settings and their supported `compact_*` aliases, plus the ratio helper's `request` / `worker` aliases; values are not renamed or defaulted during Workspace evaluation. The shared test corpus `resources/config-schema/profile-authoring-values.json` proves exact values in native/WASM analysis and effective settings through save/projection/Runtime consumption. Its canonical compaction case must cover every serialized `CompactionConfigPartial` field, making future contract drift visible in tests.
## Manifests
@@ -27,7 +74,7 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers.
Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. When Workspace evaluation imports builtins, the archive additionally seals the exact observed transitive builtin closure for the Workspace contract: canonical `$builtin/profiles/...` source keys, original Decodal bytes, content types, sizes and source digests, all bound by the outer archive and launch bundle digests. These are provenance dependencies, not runtime entrypoints or runtime import instructions. Missing or corrupt provenance fails verification even though the entrypoint is JSON; no local file or current embedded builtin can rescue it. Builtin Profiles selected directly retain their embedded Decodal archive path. Changing an embedded builtin so that it changes the evaluated Workspace value requires an **explicit config save/re-evaluation before a fresh launch**. The Backend compares current evaluation with the saved projection digest and rejects a mismatch rather than silently adopting the new value. T-711 retains the existing version-2 toolchain contract/fingerprint tuple so existing stored contracts remain readable; builtin provenance is bound by the archive and bundle digests, not a new catalog fingerprint field. Neither a builtin update nor a config save rewrites a sealed archive or silently rebases an existing Worker. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers.
## Local stdio MCP server declarations
+2 -1
View File
@@ -26,7 +26,8 @@ worker
- Controller transport は `InProcess` に固定する。通常起動で Worker subprocess、HTTP/WS server、Unix socket を作らない。
- model provider は通常の resolved Manifest から構築する。埋め込み host と deterministic test は `start_with_model_client` で同じ bootstrap に process-owned client を注入できる。
- feature plan/install は既存 `WorkerController` が行う。Task や optional direct SubWorker を standalone 側で再実装しない。
- `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。
- `shutdown()` は Host-owned Job service の scheduling を閉じ、Internal Worker の取消・cleanup とローカル SQLite connection の close を確認してから、既存 `Method::Shutdown` を送る。controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。cleanup を確認できない drop/error では lease を保持する。
- `jobs()` は Feature へ明示的に渡す汎用 Job 実行 capability。対話 Session・モデル作成 SubWorker とは別で、Profile/result authority と永続化の契約は [Standalone Jobs](standalone-jobs.md) に記録する。
- startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。
## CLI / TUI routing
+152
View File
@@ -0,0 +1,152 @@
# Host-owned standalone Jobs (T-708)
## Implementation design recorded before code changes
Base: `3c424fd17d1a5e8bb5542de2d0ad88f55338e0ea` (current origin/develop; T-704 integrated). T-709's subjektiv consumer is not part of this change.
The reusable `job` crate owns the transport-neutral immutable request (registry Profile selector, input snapshot/reference/revision, caller Job ID, instruction, serialization key and bounded limits), result envelope/digest and validation. Backend retains its existing SQLite state machine, Runtime binding, domain grants, authorization, unknown-outcome and cleanup implementation; its adapter uses the shared request validation and result contract, not a second runner.
Standalone owns a separate adapter, not a WorkspaceClient impersonation. `StandaloneHost` owns a Job service and its connection lifecycle. The concrete local placement is `<standalone state directory>/<WorkerId>/jobs.sqlite3` (using the existing Worker directory layout; alongside, not inside, interactive sessions). The `standalone` crate owns the versioned local schema: durable intents, immutable attempts, execution state, accepted result/digest, cancellation and consumer acknowledgement. A single Host connection is protected by the existing Worker lease. SQLite transactions serialize reserve, dispatch, result acceptance and terminal transitions. No subjektiv tables or fake Workspace/Runtime registry are created.
Local adapter states follow the existing intent/attempt/result distinctions. Exact immutable request replay returns the same Job, never starts another attempt. Explicit retry creates a bounded new attempt only for known failure; unknown outcomes require caller reconciliation and never auto-replay. Unstarted pending attempts remain pending on shutdown/reopen. Interrupted dispatch/running attempts without an accepted result become unknown; accepted results survive restart and remain available until consumer acknowledgement. Cancellation is durable and never auto-resumed. Consumer acknowledgement/delivery failure is separate from model success and never reruns the model.
Execution uses the existing private Internal Worker substrate with a new Host-facing bounded Job entry point. It gets a distinct ephemeral session, explicitly resolved selected Profile/system prompt/model, no ambient parent tools, filesystem, Workspace or Feature connections, and only a Job/attempt/revision-bound structured result submission capability. Profiles requiring unavailable tools/features are explicitly rejected rather than silently stripped. Result authority is independent of Profile name; final prose, Idle and Worker termination are never success evidence. This first generic adapter grants no domain operations; future consumers must provide explicit domain capabilities rather than gaining them through a profile tag.
The Host limits concurrent active resources independently of caller limits, enforces timeout/turn/output limits, routes cancellation into the Internal Worker lifecycle and waits for resource cleanup. Accepted results are committed before resource release; required domain postprocessing must finish before submission (or be independently durable in its consumer). No daemon, pool, subprocess, HTTP/WS listener or Server dependency is introduced.
Validation will distinguish scripted in-process Internal Worker executions and SQLite reopen/recovery fixtures from any real-provider/process tests. It will cover alternative Profiles, immutable retry, invalid/stale results, resource limits, failure/cancellation/timeout, shutdown and restart. Backend focused regression tests must still prove its existing fences and cleanup.
## API and ownership
`job::{JobRequest, JobLimits, JobResultSubmission, JobOutcome}` are the common
Feature-facing data contract. `JobOutcome` distinguishes intent state from attempt
state and omits Workspace/Runtime identities. These enums are read vocabulary,
not another transition engine. Backend converts neutral requests with
`BackendJobRequest::from_job_request` and projects reservation/acceptance with
`.outcome()`; Backend domain grants, provenance, delivery and cleanup remain on
its own adapter and retain their existing durable/wire format and fingerprints.
Standalone consumers receive `StandaloneHost::jobs()` explicitly, not a fake
Workspace client and not a tool on the interactive Worker. They can persist
`request`, read `get`/`pending`, explicitly `start` or `retry`, `cancel`, and
`acknowledge`. `.wait()` observes execution cleanup; `.outcome()` on the returned
snapshot is the common read view. `Completed` with its accepted structured result
is success authority; merely returning from `wait` is not. The injected-client
entry point resolves/validates the same selected Profile and model configuration;
it replaces only the model transport for embedders/tests.
A typical generic consumer supplies its own stable Job ID/revision/reference,
immutable JSON input, instruction and registry Profile:
```rust,ignore
let jobs = host.jobs();
let snapshot = jobs.request(request)?;
if snapshot.state == standalone::jobs::JobState::Pending {
jobs.start(&snapshot.request.job_id).await?;
}
let outcome = jobs.wait(&snapshot.request.job_id).await?.outcome();
// Validate/apply the consumer's domain result here. A failed notification or
// delivery leaves the successful result unacknowledged; do not retry the model.
if outcome.state == job::JobState::Completed {
deliver_idempotently(&outcome)?;
jobs.acknowledge(&outcome.job_id)?;
}
```
The Host retains tasks/cancellation even while a consumer waits; dropping a wait
future does not detach cancellation ownership or release a concurrency slot.
Active resources are bounded to eight by the Host and by the minimum of all live
requests' ceilings, including after result acceptance and before cleanup.
Serialization keys likewise fence live resources, not just pending result state.
Busy `start` returns a typed capacity error and leaves the reserved attempt
unchanged; callers decide when to start/resume pending work. There is no pool,
background daemon, implicit retry or independent Feature-owned Internal Worker.
## Profile and capability policy
The caller must select an existing built-in or user registry Profile. Path/raw
selectors and `inherit` are not accepted. Registry existence, selected instruction
and model configuration are checked before dispatch. The ordinary user settings
registry and Prompt catalog are used; repository-local recipes are not discovered
by the standalone Host, and default never silently maps to a different Profile.
`builtin:job` is an **optional**, compatible result-only policy. It is not a
runner-selected default or authority token. User registry Profiles with different
instructions/models/bounds execute the same way, as demonstrated by the scripted
Worker tests. This adapter deliberately rejects active unsupported Feature,
filesystem/delegation scope, MCP, skills, compaction and durable event-trace
requirements. Existing `builtin:backend-job` has such requirements and is rejected
by this standalone adapter, while its Backend behavior is unchanged. A future
consumer requiring domain operations must arrange explicit Host capabilities;
choosing `builtin:subjektiv-memory-consolidation` alone grants nothing.
Every run uses the existing private Internal Worker substrate, a distinct identity
and ephemeral Session. Only `SubmitJobResult` is installed by a FeatureModule.
Its model input is `{result: ...}`; Job/attempt/input-revision bindings come from
the Host capability, not model arguments. The sink checks those bindings, the
current persisted attempt, deadline, byte limit and result digest. Exact result
replay is idempotent; changed/foreign/stale results are rejected. Submission is
sealed before acceptance, so uncertain responses permit only identical retries.
An ambiguous sink failure is reconciled against SQLite; absent accepted evidence
it is `Unknown`, not a known retryable model failure. No domain logic is embedded
in this sink. Required consumer/domain postprocessing must complete before
submission or have its own durable ownership outside the ephemeral Worker.
Selected instructions are retained. The Host execution contract is rendered from
`resources/prompts/internal/job_system.md` before the run's initial history is
created, not injected into a previous/transient turn. Turn/output policy is
attenuated to at most 32 turns and 8192 output tokens per model request, preserving
tighter selected Profile bounds. Structured results retain the shared 16 KiB
default / 64 KiB absolute byte limits, not a larger standalone exception.
## Persistence and termination
Local schema version **1** is owned only by `standalone::job_store`: `job_intents`
contains immutable serialized intent/fingerprint, serialization key, current
attempt and acknowledgement; `job_attempts` contains immutable identity/revision,
execution state, failure and accepted result/digest. Foreign keys, immutable
identity/result triggers, transactions, `WAL` and `synchronous=FULL` protect the
boundary. No Server schema or subjektiv/Memory schema is copied or opened.
The Host opens and recovers the store only under its existing top-level Worker
lease; clones cannot open or recover it themselves.
- **Exact request replay:** same immutable intent returns its current snapshot.
Changing any intent field under the same ID is a conflict, not a new attempt.
- **Known failure:** only explicit `retry` creates a new immutable attempt, up to
the intent's bounded maximum. Prior attempts/results remain in SQLite.
- **Timeout/shutdown before acceptance:** fence `Unknown` before requesting Engine
cancellation; late result submissions cannot create success. Await normal
cleanup; retain the Host lease and report an error if confirmation is lost.
- **Normal Host shutdown:** seal scheduling/mutations, cancel active runs, await
resource cleanup, close SQLite, then stop the interactive controller and release
its lease. Reserved/unstarted requests remain resumable. Old cloned handles
return `Closed`, rather than mutating a store reopened by another Host.
- **Unexpected Host drop:** request cancellation and retain the lease because
synchronous drop cannot attest cleanup. No daemon continues after process exit.
- **Process loss/reopen:** reserved attempts stay pending; unaccepted dispatched
attempts become unknown; accepted results/acknowledgements remain intact.
SQLite result evidence, never final prose or an ephemeral Session, decides success.
- **Cancellation:** durable and never automatically resumed. Cancelling already
unknown intent does not erase its attempt's uncertain execution evidence or
release its serialization fence. `retry` still refuses it. There is no generic
operation that declares uncertain domain side effects unexecuted; callers must
reconcile domain evidence rather than bypassing the fence with an implicit replay.
- **After acceptance:** model error, cancellation or consumer delivery failure
cannot erase success or rerun the model. Acknowledgement is independently durable
and idempotent. Execution resources are reclaimed independently of result lifetime.
Emergency cancellation has a bounded grace (one second for cancellation setup,
two seconds for normal cleanup). Expiry is **not** cleanup confirmation: it returns
an error, keeps unknown evidence/accepted results, and the Host retains its lease.
The result-only Feature owns no async/background tasks or domain connections;
the dropped engine stream/tool pumps request abort. Synchronous SQLite submission
is bounded by its five-second busy timeout and cannot be preempted mid-transaction.
No resource or side effect is claimed rolled back merely because a future dropped.
## Validation evidence
See [T-708 validation ledger](../report/2026-10-06-t708-validation.md). It separates
scripted in-process Worker/Host runs, direct SQLite/reopen/rollback fixtures and
an actual test-owned child-process loss/Host restore test from live-provider or
live dogfood validation. T-709's subjektiv consumer is intentionally not migrated
here; Backend T-704 Profile selection and T-690 cleanup remain regression-tested.
+41
View File
@@ -0,0 +1,41 @@
# Standalone subjektiv (T-709)
## Implementation design, recorded before implementation
Base: `13055d7ee18f9d29edc641d0bd0e57c3d034dfa0`, including T-704 and T-708. No Backend database, fake Workspace, local fallback, new daemon, or subjektiv-specific Internal Worker runner is introduced.
The operator-managed standalone state directory (existing CLI/XDG standalone state setting, not cwd or a repository file) is the local sharing boundary. `<state_dir>/subjektiv/` is a private directory. A persisted random storage-scope identity is issued here; Subject IDs use the common domain UUID issuer, never cwd/Profile/Worker name. The common Feature SQLite manager opens the subjektiv database under this root; it owns connection options, migration execution, close/reopen and transaction locking. The common subjektiv crate owns the existing schema/migrations/domain/CAS/receipt/surface rules. Existing Backend layout and physical schema remain unchanged. Job domain state is not embedded into Memory tables or Session text.
Subjects are explicitly created/listed in that state scope and selected at normal launch; enabled policy alone does not create or attach one. Each selected Subject has a separate OS advisory lock file under `<state_dir>/subjektiv/leases/`. The Host holds the exclusive lock for body execution and all delegated Jobs until confirmed shutdown. A second process cannot acquire the same lock. Process death releases the kernel lock, not a PID/Profile guess; uncertain in-process cleanup retains ownership. A separate Subject can run concurrently, with SQLite serializing common database transactions. Symlinks and non-private state nodes must be rejected at this boundary. Knowledge of an ID/path is not a model capability.
The selected Subject and local storage-scope identity are persisted in the standalone Worker record. Restore verifies them and reacquires the original Subject lock; resume never silently changes Subjects. Subject-free and disabled-policy launches retain the old path with no subjektiv persistence side effects. Ordinary SubWorkers/Reviewers receive no independent Subject/extraction connection.
The Feature receives an explicit Host-scoped subjektiv capability, separate from WorkspaceClient and Profile policy. Backend adapts its existing authenticated API operations; standalone binds one local store, Subject, Worker and committed Session authority. Session attribution is established by the Host, including Sessions without candidates. Reads use the existing committed public Session projection and standalone Session store, not a copy in the Memory database. No system prompt, hidden reasoning, uncommitted history or arbitrary filesystem/foreign-Subject access is granted.
Consolidation uses the existing generic standalone Jobs service and common request/result contracts. For a selected Subject the Host owns `<state_dir>/subjektiv/jobs/<SubjectId>/jobs.sqlite3` under that Subject lease, allowing durable intents/unknown outcomes/results to follow it across Workers. Subject-free Hosts retain the T-708 Worker-local location. Subjektiv selects the consolidation Profile. Job capabilities are explicitly attenuated to the immutable candidate batch, recall/decision/surface operations and public Session references; they never confer the Subject body's execution lease or ambient parent filesystem/Workspace tools. Common receipt/CAS rules handle partial apply and response loss; unknown execution is never automatically replayed. Surface failure leaves confirmed Memory intact. The generic runner owns cancellation, timeout, result binding, resource cleanup and shutdown; processing stops with the standalone process and pending durable work is recovered only on a subsequent explicit Host connection.
Validation must distinguish scripted model/Host and real test-owned process/lock runs from live-provider model quality. Root `cargo check`, changed-crate tests and narrow Backend/Feature/Job regression tests are required, plus format/diff checks. Concrete CLI/UI usage and recovery are documented in [standalone.md](../standalone.md).
## Implemented boundaries and validation
- `feature-storage` owns the shared SQLite connection policy/Feature manager; `subjektiv` owns the unchanged domain schema/migrations, Host-neutral API dispatch, and T-704 consolidation request/result/surface validation. Server modules remain thin adapters; Worker/standalone do not depend on Workspace Server.
- `worker::subjektiv` exposes a nonserializable live Host capability plus a separate attenuated consolidation capability. Backend creation still requires the actual authenticated client and bound snapshot. Local creation is explicit Host injection; unavailable Backend never falls back locally.
- The local catalog keeps private scope/language settings and common Feature storage under `subjektiv/`, separately from generic Subject Job ledgers. The common attribution table's existing `runtime_id` lookup key is the local storage namespace for local rows, not a running Runtime identity; public local evidence leaves `runtime_id`, Workspace, and Account fields absent. Worker IDs, Subject binding, and operator-managed scope must all match before Session content is read.
- The shared strict FsStore public index is also used for committed evidence admission. Stable references are intersected with visible committed entries; numeric hints accept the two existing capture producers. Open-Run evidence is deferred only before a request, never exposed by Session tools or recorded before commit. Persisted local human provenance is distinct from Backend Account provenance and maps to the existing public `HumanInput` class.
- Standalone integration tests use real Hosts/SQLite, scripted extraction/consolidation/surface models, and test-owned second processes. Nine cases cover inert/disabled policy, reopen, saved-binding restore, cross-Worker recall/source discovery, exact explicit commit staging, normal extract/pointer → generic Job → CAS apply → resident surface, same-Subject cross-process exclusion/abnormal-exit recovery, and transport loss after apply followed by surface-only repair without duplicate Memory revision.
- Generic Job tests additionally cover immutable grant persistence, missing/foreign/revision/consumer grants, per-attempt/deadline revocation, cancellation, timeout, cleanup failure, and stale capabilities. Session adapter tests cover no-candidate discovery, foreign Subject/scope/Worker denial, bounded UTF-8 and JSON output, filter/generation-bound cursors, missing/corrupt storage, and hidden/uncommitted/partial-tail exclusion without observation writes.
Verified commands:
```sh
cargo test -p standalone --test subjektiv -- --test-threads=1
cargo test -p protocol -p feature-storage -p subjektiv -p manifest -p session-store -p standalone -p worker -p tui -p yoi -p yoi-workspace-server
cargo test -p worker-runtime --lib protocol_
cargo check
cargo check -p protocol --features typescript,json-schema
cargo check -p tui --features e2e-test
cargo fmt --all -- --check
git diff --check HEAD
```
The changed-crate run passed, including 836 Worker unit tests, 603 Workspace Server unit tests (authorization/singleton/T-704 regression), 48 standalone unit tests, and nine standalone Subject integration tests. Existing ignored tests remain ignored. TypeScript generation check reports existing unsupported serde-attribute warnings but succeeds. These commands prove mechanism/authorization/storage contracts, not live-model quality or full interactive terminal E2E behavior.
+1 -1
View File
@@ -165,7 +165,7 @@ Ticket/Objective/Merge Request routes retain their T-698 contracts.
## First-Workspace example
1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and
1. `Discover(path="/", depth=1)` finds `repositories`, `workdirs` and
`workdir-attachments` when permitted.
2. Inspect the returned Repository collection Interface and call `list` with a
bounded limit. An empty registered/visible catalog explicitly returns an empty
+7 -7
View File
@@ -30,9 +30,9 @@ The implementation uses the crates.io `wip-client`, `wip-protocol`, and `wip-htt
A WIP Worker exposes exactly these generic LLM tools instead of exposing every enabled ordinary tool twice:
- `WipDiscover`: observe an object path and bounded descendants, beginning at `/`.
- `WipInspect`: fetch the descriptor for an opaque interface reference returned by discovery.
- `WipCall`: call an operation using fresh object and interface observations.
- `Discover`: observe an object path and bounded descendants, beginning at `/`.
- `Inspect`: fetch the descriptor for an opaque interface reference returned by discovery.
- `Call`: call an operation using fresh object and interface observations.
The compatibility projection mounts enabled ordinary tools at:
@@ -46,7 +46,7 @@ Each object has one opaque interface reference:
yoi.tool/<exact-tool-registration-name>/v1
```
Its single operation is `call`. For this compatibility route, `WipCall.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
Its single operation is `call`. For this compatibility route, `Call.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
The interface descriptor uses WIP `Json` for compatibility input and includes the exact original JSON Schema in descriptor documentation. Before execution, the Host validates the JSON value with that original schema and then delegates to the original async `Tool`. Constraints are therefore neither approximated nor silently dropped. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection.
@@ -65,7 +65,7 @@ Object resolution and operations are separate contracts. One mounted `WipProject
- A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route.
- Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry.
`WipInspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation.
`Inspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation.
The built-in native route migration is:
@@ -103,13 +103,13 @@ The Worldspace contains only the operations registered from enabled Features and
Filesystem scope, provider Workdir capability checks, Backend Workspace authority, Feature enablement, and input-specific restrictions stay in the original tool implementation and are checked again at invocation. `ask` permissions remain denied fail-closed because the runtime has no approval protocol. The gateway does not mint authority or expose authentication material.
Pre/post Engine history still records one bounded `WipCall` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification.
Pre/post Engine history still records one bounded `Call` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification.
## Client lifetime, restoration, and compaction
The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions are keyed by the canonical endpoint and an opaque security-context identity derived by the Host from Workspace, Worker, and session identity. Authentication credentials are not included in that identity and are never emitted to descriptors, tool output, or history.
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority.
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `Discover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority.
Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features/<feature>/...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel.
@@ -0,0 +1,12 @@
# Review finding severity の入力契約
T-710 の独立レビュー中、Reviewer が `ReviewMergeRequest` に finding severity `medium` を渡し、HTTP 422 で拒否された。Backend の許可値は `blocker` / `major` / `minor` / `note` で、最初の呼び出しでは verdict が記録されなかった。
親は committed session と MR thread を確認して未記録であることを確かめ、Reviewer の新しい turn で同じ capability・同じ MR/source に対して `major` を使った訂正を依頼した。訂正は成功し、MR sequence 3 に `request_changes` が記録された。別の保存経路や Ticket comment で verdict を代替しなかった。
## 改善案
- finding severity は公開 tool schema でも Backend と同じ enum として示し、受理可能な語彙を入力前に確認できるようにする。
- capability を使う操作の検証エラーでは、イベントが未記録であることと、訂正した入力を再送できるかを明確に返す。Reviewer が「一回の呼び出し」と「一回の有効な verdict 記録」を混同し、未記録のまま止まる障壁を減らせる。
これは observed rejection と訂正成功に基づくフィードバックであり、Backend の review authority を迂回する提案ではない。
+134
View File
@@ -0,0 +1,134 @@
# T-708 implementation and validation ledger
Base: clean detached assigned checkout `3c424fd17d1a5e8bb5542de2d0ad88f55338e0ea`,
also the inspected `origin/develop`. T-704 was authoritative `done` with that
integrated source. Created `work/T-708-standalone-jobs` without overwriting an
existing branch or discarding changes. T-709's subjektiv consumer is separate.
The concrete SQLite placement/schema ownership was recorded in
[the design](../design/standalone-jobs.md) before implementation; it uses the
existing `<state_dir>/<WorkerId>` directory layout, not a new `workers` subdirectory.
Three scoped implementation children supplied the common contract/Backend
projection, Worker execution adapter, and local SQLite store. Parent inspected
committed sessions, exact source changes and concrete test results, then integrated
Host/service ownership, live-resource ceilings, deadline fencing, independent
acknowledgement, shared outcome projection, process-loss tests and documentation.
Independent published-MR review is separate from these implementation children.
## Implemented boundaries
- `job`: neutral immutable request/limits/result envelope, fingerprint, strict
registry selectors, bounded digests and read-only intent/attempt/outcome DTOs.
No HTTP, Workspace, Runtime identity, domain grants, runner or workflow DSL.
- Backend reuses validation/result helpers and neutral projections while retaining
its exact request wire/fingerprint/envelope, domain grants, persistent states,
Runtime authorization, unknown fences, resource serialization and cleanup.
- `worker::job`: selected registry Profile/instruction/model, explicit requirement
rejection, bounded policy, distinct ephemeral Internal Worker, result-only
FeatureModule and Host-fenced structured acceptance. No parent filesystem,
Workspace, Feature or domain connections; no Profile-name authorization.
Ambiguous sink acceptance is Unknown unless the Host's durable result proves success.
- Standalone Host owns schema-1 SQLite intent/attempt/result/ack state and a bounded
execution service. It opens and recovers under its Worker lease; shutdown cancels
and awaits executions, closes SQLite, then releases the lease after controller
confirmation. Old cloned handles are closed. Drop cannot attest cleanup and
retains the lease. Busy/unknown/cancelled/terminal work is not blindly replayed.
- Exact immutable request resend differs from explicit bounded new-attempt retry.
Results commit before resource release, survive model/notification failure, and
are independently acknowledged by the consumer. Waiting does not relinquish
Host ownership of cancellation/concurrency. Cancelling unknown intent retains
its uncertain attempt evidence and serialization fence.
- Optional `builtin:job` is a caller-selected compatible policy, not a fixed runner
default. Different user registry Profiles are demonstrated in Worker fixtures.
Existing `builtin:backend-job` retains Backend behavior; standalone rejects its
unavailable requirements rather than silently stripping or replacing them.
- No production Server/Runtime process, HTTP/WS listener, Worker registry,
subprocess Worker, daemon/pool or subjektiv-specific runner is added.
## Validation results
Development began with narrow contract/module targets before full changed-crate
validation. Final root check covers the normal default-members TUI/Server closure.
| Command | Concrete result |
| --- | --- |
| `cargo test -p job` | 9 contract tests passed; no unit/doc tests |
| `cargo test -p standalone --lib job_store` | 19 fixtures passed: transactional replay/conflict, all limits, result fences, retry/old-attempt preservation, cancellation, concurrency races, SQL rollback, schema rejection and reopen recovery |
| `cargo test -p standalone --lib jobs::tests` | 2 passed: Host capability binding/deadline/replay and success/cancellation/closed-handle lifetime |
| `cargo test -p standalone --test jobs process_loss_recovers_durable_jobs_without_replaying_unknown` | Actual test-owned process exit and Host stale-lease/SQLite recovery passed |
| Final `cargo test -p standalone` | 25 library + 10 existing Host integration + 8 Job integration passed; 1 child-process fixture entry point marked ignored in parent harness, explicitly invoked with `--ignored` by the passing process-loss test |
| `cargo test -p manifest` | All 185 tests passed, including new optional builtin Profile catalogue resolution; no resolver/runtime/schema implementation changed |
| `cargo test -p worker` | All 826 library + 110 integration tests passed, including 11 new Job tests and 8 existing Internal Worker substrate tests |
| `cargo test -p yoi-workspace-server --lib backend_job` | All 38 passed: neutral adapter/wire/fingerprint, T-704 Profiles/no fallback, structured fences, unknown/recovery, resource serialization and T-690 cleanup |
| `cargo test -p yoi-workspace-server --bins` | All 11 binary tests passed |
| `cargo test -p worker-runtime --lib backend_job` | All 3 Job binding/dispatch regressions passed |
| `cargo test -p server-api --features typescript` | All 77 passed, including checked-in generated/OpenAPI/TypeScript freshness; existing ts-rs serde-attribute warnings |
| Root `cargo check` | Passed after integration and again after final local adapter/tests changes |
| `cargo fmt --all -- --check` / `git diff --check HEAD` | Passed |
| `cargo test -p yoi-workspace-server` | **Failed: 643 passed, 1 failed** in library; see retained anomaly below. Binary tests were therefore run separately. This is not a green full Server suite. |
| `cargo test -p yoi-workspace-server -- --test-threads=4` | **Failed: 643 passed, 1 failed**, same unchanged checker assertion; not claimed passed |
Profile fixtures use two different existing user registry entries with distinct
instructions and budgets, plus optional `builtin:job`. They prove only
`SubmitJobResult` is exposed, selected instructions/policy are retained, scopes and
Backend/domain tools are absent, malformed/oversized/forged results are rejected,
uncertain sink submission permits only identical replay, final prose is not
success, provider failure/cancellation/turn limits do not manufacture success,
and accepted results survive later provider failure.
Host Job integration additionally proves explicit known-model-failure retry,
request replay, live concurrency while a consumer waits, cancellation, timeout,
shutdown, pending resume, consumer acknowledgement and unsupported Profile
rejection without model calls. Direct-store fixtures prove persisted facts and
rollback/reopen behavior, not live model or process behavior.
### Actual process verification versus fixtures
`process_loss_recovers_durable_jobs_without_replaying_unknown` starts **only a
test-owned copy of the integration test executable**, using scripted model clients.
The child owns a real StandaloneHost/SQLite connection/Worker lease, persists a
reserved request, a cancelled request and an accepted result, then exits with code
23 while a separate Internal Worker is executing. It deliberately skips destructors
and graceful shutdown. The parent verifies that exit and restores the Host through
the real stale-process lease path: accepted result retained/unacknowledged,
cancelled work never resumed, reserved work explicitly resumable and interrupted
work Unknown/no automatic retry. The helper's ignored annotation is an entry-point
selector, not skipped semantic coverage.
These are real Rust test/compiler processes plus scripted in-process model
executions and SQLite fixtures. They are **not** live-provider LLM calls, a live
standalone CLI/TUI session, a Backend/Runtime deployment or dogfood environment
update. No operating Yoi Worker or live dogfood process was stopped or updated.
Production Jobs do not create the subprocess used solely by that crash test.
## Retained failures and evidence limits
Initial integrated compilation exposed `get` snapshot/Option disagreement between
concurrently authored store/service; signatures were aligned and the subsequent
store/Host/full standalone runs passed. A private tool-type import during child
integration was corrected before focused/full Worker validation. No passing
result is attributed to those initial failing builds.
Both full Server runs and one isolated run of
`server::tests::ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output`
failed the unchanged post-stale-result `assert!(execution.take_inputs().is_empty())`
at `server.rs:44748`. All 38 Job-filter tests pass, including unchanged Backend
envelope/fingerprint compatibility and late-result/cleanup fences. The same
checker assertion/race was independently reproduced on the earlier original base
in the [T-702 ledger](2026-10-06-t702-stale-checker-validation.md), and explicitly
retained as unfixed in the [T-704 ledger](2026-10-06-t704-validation.md).
No new isolated build of this Ticket's exact base was run, and its root cause is
not claimed proven or fixed here. The checker implementation/test was neither
changed nor skipped to manufacture a green suite. This validation anomaly remains
visible for Reviewer/Orchestrator attention.
No whole-workspace tests, E2E, Nix/Docker builds or live-provider validation were
claimed. Backend generated contracts were regression checked despite no wire
change. Result-only Jobs reject unsupported Feature/authority requirements;
subjektiv consumer capabilities/postprocessing are T-709 work, not inferred grants.
Publication must use only the clean committed Ticket work branch by normal
non-force push, verified with exact provider source ref. No target/tags/unrelated
refs, replacement MR, integration branch, merge or Ticket completion is performed
by this Coder. Final immutable source/MR/review identities belong in the MR thread
and approved Ticket handoff rather than being guessed in this ledger.
+66
View File
@@ -0,0 +1,66 @@
# T-711: nested lazy-object import cycle
Date: 2026-10-06
Status: fixed at the snapshot import-loader boundary; regression coverage retained.
## Dogfooding problem
During T-711 builtin-import validation, a nested import cycle in a lazy object caused a stack overflow instead of a recoverable configuration diagnostic. A configuration author could therefore terminate evaluation while writing an invalid recipe, rather than receive an error and keep the active revision. This report records the implementation-session failure and the guard added by the parent config-source implementation; it is not a recommendation to rerun the crashing version inside a live Yoi Worker.
## Minimal reproducer
Build a `ConfigTreeSnapshot` containing these two Decodal entries:
`main.dcdl`:
```dcdl
{
builtin = import "$builtin/profiles/companion.dcdl";
cycle = import "./cycle.dcdl";
}
```
`cycle.dcdl`:
```dcdl
import "./main.dcdl"
```
Evaluate the snapshot with `SnapshotEnvironment::evaluate_contract` and a `ToolchainContract` whose entrypoint is `main.dcdl`. This fixture intentionally uses the plain evaluation contract rather than `WorkspaceConfigSchema`: it isolates import/materialization behavior, not Profile registration semantics.
Before the loader guard, evaluating the root object could defer its `cycle` field until materialization. Importing the root again through `cycle.dcdl` then recursed through that lazy value instead of returning a structured cycle failure. The observed failure was a process stack overflow. Increasing the stack would only postpone the failure and would not provide a safe configuration boundary.
## Fix
`SnapshotImportLoader::load` now records canonical source-to-source edges and validates the observed graph **before returning a source to Decodal**. Content-digest cache suffixes are stripped for graph identity, so the same virtual source cannot evade cycle detection by appearing under a cache key.
- A back edge returns a `cycle` diagnostic before lazy materialization can recurse.
- An import chain longer than 32 edges returns an import-depth limit diagnostic. The synthetic evaluation wrapper's entrypoint import counts toward that limit.
- Memoized dependency depths keep shared-subgraph validation bounded without losing the longest path.
- Rejected edges are removed from the successful-edge cache. A reused language-service loader must reject the same bad edge again, not treat it as already validated.
- Workspace and builtin imports use the same graph guard and shared source count/byte budgets. Embedded sources are not an exception.
The fix belongs in `crates/config-source/src/lib.rs`; it does not require runtime Decodal evaluation. Project launch archives still transport the evaluated JSON entrypoint with builtin source provenance, and Runtime resolves only that evaluated value.
## Regression evidence
The following tests in `crates/config-source/src/lib.rs` cover the incident and adjacent guard behavior:
- `builtin_values_do_not_bypass_cycle_or_path_limits`: the two-entry lazy-object fixture above returns `kind = "cycle"`; builtin imports do not bypass depth/path bounds.
- `rejected_import_graph_edges_stay_rejected_after_diagnostics`: repeated attempts to add the rejected back edge remain errors when the loader is reused.
- `import_graph_shared_dependencies_are_memoized_without_losing_depth`: shared dependencies preserve longest-path validation and the allowed depth boundary.
- `snapshot_import_depth_is_checked_before_evaluation_recurses`: a real source chain fails with an import-depth diagnostic before evaluation recurses beyond the guard.
- `builtin_dependencies_share_snapshot_count_and_byte_budgets`: builtin dependencies cannot exceed the aggregate entry-count or byte budget by living outside the authored tree.
Run individual regressions with the normal narrow target, for example:
```sh
cargo test -p config-source --lib builtin_values_do_not_bypass_cycle_or_path_limits
cargo test -p config-source --lib snapshot_import_depth_is_checked_before_evaluation_recurses
```
The server follow-up test `value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back` separately exercises authenticated HTTP commits: reserved builtin mutations and unknown imports are rejected without changing the active revision, even when a Workspace file exists at the unknown builtin's suffix. Editor import completions remain covered by the WASM editor tests; no server completion endpoint was introduced.
## Operational lesson
An evaluator's module cache is not sufficient evidence that lazy-value cycles are safe. Keep cycle and depth validation at the source-loader boundary and retain the lazy-object reproducer whenever import identity, caching, or materialization changes. Config errors should be recoverable diagnostics, never an implicit request to replace a running Worker's persisted Manifest or restart the live dogfooding process.
+52
View File
@@ -0,0 +1,52 @@
# Standalone local Subjects
Standalone runs one client-owned Worker in the current process, without a Backend or Workspace. Local Subjects are explicitly created and selected; enabling a subjektiv Profile alone neither creates nor attaches a Subject.
## Create and list
```sh
yoi --local subject create assistant --behavior-md 'Keep durable decisions and preferences.'
yoi --local subject list
```
`create <ROLE>` prints the created Subject record as JSON. `--behavior-md <TEXT>` is optional inline text, not a file path. `list` prints a JSON array of records in the selected standalone state scope. Both commands require explicit `--local` and reject Backend/Workspace selectors.
The state directory is obtained from the same local connection target as normal standalone launch. Its base follows the existing `YOI_DATA_DIR`, `YOI_HOME`, `XDG_DATA_HOME`, or `HOME` resolution, with `client/standalone/workers` appended. Subjects live under `<state_dir>/subjektiv/`; cwd, repository files, Worker names, and Profiles are not Subject identity or sharing boundaries. Use the returned common-domain Subject ID (`subject-<UUID>`), not the role, for selection.
The user-managed state root must be operator-owned and private (mode **0700**). A missing root is created with mode 0700; an existing root is validated, not automatically chmodded. An older nonprivate root is rejected. The operator can explicitly run `chmod 700 <state_dir>` on their own state root before retrying; do not substitute a repository directory or assume the CLI repairs permissions. Symlink and nonprivate Subject storage nodes are also rejected.
## Select for a new Worker
```sh
yoi --local --subject <ID> --profile builtin:standalone-subjektiv
```
`--subject=<ID>` is also accepted. The selector must be nonempty and may only be supplied once. It applies only to fresh local spawn: Backend selection, Runtime attach, socket/session attach, and resume combinations are rejected. A selected Subject requires a compatible subjektiv policy; launch validation rejects incompatible policy before Worker startup side effects.
The interactive local spawn form has an optional `subject` field, initially `(none)`. Press **F2** to switch between editing the Worker name and Subject ID; **Tab/Up/Down** continue to cycle Profiles. Leave the Subject field empty for the existing Subject-free launch path. The form does not create Subjects or open Subject storage just to display the field. The optional built-in `builtin:standalone-subjektiv` Profile enables local Subject behavior; its consolidation Profile is `builtin:standalone-subjektiv-consolidation` and does not require Backend resources.
## Resume
```sh
yoi --local resume
```
Resume restores the original persisted Subject/storage-scope binding and reacquires its execution lease. It does not accept a new `--subject` selector or silently change Subjects. The Host holds an exclusive per-Subject kernel lease for body execution and delegated Jobs until confirmed shutdown; one Subject cannot be executed concurrently by two processes. Process death releases the kernel lease, while uncertain in-process cleanup retains ownership. Separate Subjects may run concurrently in the same state scope.
## Subject Jobs and recovery
A selected Subject uses `<state_dir>/subjektiv/jobs/<SubjectId>/jobs.sqlite3`, shared across Worker IDs for that Subject and protected by its exclusive execution lease. Subject-free Hosts retain the existing Worker-local Job location. There is no separate Subject runner or background daemon.
On explicit selected-Subject Host startup, reserved Jobs resume automatically. An interrupted Job whose execution outcome is unknown is **never automatically replayed**. A successful result that has not yet been acknowledged is delivered from durable storage without retrying the model. Recovery is driven by an explicit Host startup, not an automatic process restart or independent scheduler.
## Memory and historical Sessions
The normal body Profile has recall, revision reads, explicit candidate tools, and bounded historical Session exploration. It cannot directly apply Memory. Explicit Remember/Revise without `entry_refs` returns a pending-commit receipt; the Host stages its exact tool-call evidence only after the logical Run is append-only committed. Pre-request retry may defer an open Run, but commit/rewrite failures are not silently treated as success. A candidate remains unconfirmed until the restricted consolidation Job applies or rejects it using the shared CAS/receipt rules.
Normal extraction uses the existing committed-capture lifecycle, restricted extraction tools, and append-only `subjektiv.extract.v1` pointer. The selected Profile owns extraction thresholds/model policy. An explicit extraction model override chooses that provider, not an injected dialog transport. Local Memory language defaults to English and is stored in operator-managed `subjektiv/language.json`, never loaded from repository policy files.
The Host stamps human input on its owned in-process client channel as persisted `local_human_input`, projected as the existing `HumanInput` provenance. It neither invents a Backend account nor trusts serialized source claims. Ordinary SubWorker/Job input retains its own nonhuman origin. Local evidence has no Workspace/account/Runtime identity; local storage-scope identity and Worker binding authorize discovery separately. Historical Sessions are not copied into SQLite. Reads/search exclude system prompts, hidden reasoning, incomplete logical Runs, partial JSONL tails, and foreign bindings; cursor continuations revalidate scope/Subject/Worker attribution and committed generation.
## Validation boundary
Host injection tests pass a clone of the injected model transport into delegated Jobs, so scripted body and Job calls use the same test-owned transport. These are local contract tests, not live-provider validation or evidence of model/consolidation quality. Generic Job finalizer and Host integration proofs are validated separately from the CLI/parser and spawn-form tests.
+8 -1
View File
@@ -1235,6 +1235,13 @@
"ConfigSchemaContribution": {
"additionalProperties": false,
"properties": {
"authoring_source": {
"description": "Completion-only shape for partial values; never materialized as defaults.",
"type": [
"string",
"null"
]
},
"namespace": {
"type": "string"
},
@@ -15474,7 +15481,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da"
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
},
"openapi": "3.1.0",
"paths": {
@@ -0,0 +1,116 @@
[
{
"name": "canonical_compaction_tokens",
"profile": {
"compaction": {
"kind": "tokens",
"prune_enabled": false,
"prune_protected_tokens": 3000,
"prune_min_savings": 1000,
"threshold": 80000,
"request_threshold": 90000,
"retained_tokens": 12000,
"overview_target_tokens": 1000,
"overview_warning_tokens": 2000,
"overview_deadline_tokens": 3000,
"worker_context_max_tokens": 20000,
"finish_warning_remaining_tokens": 1000,
"final_reserve_tokens": 2000,
"worker_max_turns": 5,
"summary_target_tokens": 1000,
"summary_max_tokens": 2000,
"auto_read_budget_tokens": 500,
"result_context_max_tokens": 4000,
"model": { "ref": "codex-oauth/gpt-5.6-sol" }
}
},
"manifest_expect": {
"/compaction/prune_enabled": false,
"/compaction/prune_protected_tokens": 3000,
"/compaction/prune_min_savings": 1000,
"/compaction/threshold": 80000,
"/compaction/request_threshold": 90000,
"/compaction/retained_tokens": 12000,
"/compaction/overview_target_tokens": 1000,
"/compaction/overview_warning_tokens": 2000,
"/compaction/overview_deadline_tokens": 3000,
"/compaction/worker_context_max_tokens": 20000,
"/compaction/finish_warning_remaining_tokens": 1000,
"/compaction/final_reserve_tokens": 2000,
"/compaction/worker_max_turns": 5,
"/compaction/summary_target_tokens": 1000,
"/compaction/summary_max_tokens": 2000,
"/compaction/auto_read_budget_tokens": 500,
"/compaction/result_context_max_tokens": 4000,
"/compaction/model/ref": "codex-oauth/gpt-5.6-sol"
}
},
{
"name": "compaction_token_aliases_without_kind",
"profile": {
"compaction": {
"compact_threshold": 80000,
"compact_request_threshold": 90000,
"compact_retained_tokens": 12000,
"compact_worker_max_input_tokens": 20000,
"compact_worker_max_turns": 5,
"compact_auto_read_budget": 500
}
},
"manifest_expect": {
"/compaction/threshold": 80000,
"/compaction/request_threshold": 90000,
"/compaction/retained_tokens": 12000,
"/compaction/worker_context_max_tokens": 20000,
"/compaction/worker_max_turns": 5,
"/compaction/auto_read_budget_tokens": 500
}
},
{
"name": "compaction_ratio_aliases",
"profile": {
"model": { "context_window": 100000 },
"compaction": {
"kind": "ratio",
"threshold": 0.9,
"request": 0.8,
"worker": 0.2
}
},
"manifest_expect": {
"/compaction/threshold": 90000,
"/compaction/request_threshold": 80000,
"/compaction/worker_context_max_tokens": 20000
}
},
{
"name": "numeric_extraction_reasoning",
"profile": {
"engine": { "reasoning": -1 },
"feature": {
"memory": { "extraction": { "enabled": true, "reasoning": 4096 } },
"subjektiv": { "extraction": { "enabled": true, "reasoning": 4096 } }
}
},
"manifest_expect": {
"/engine/reasoning": -1,
"/feature/memory/profile/extraction/reasoning": 4096,
"/feature/subjektiv/profile/extraction/reasoning": 4096
}
},
{
"name": "string_extraction_reasoning",
"profile": {
"feature": {
"memory": { "extraction": { "enabled": true, "reasoning": "high" } },
"subjektiv": {
"extraction": { "enabled": true, "reasoning": "medium" }
}
}
},
"manifest_expect": {
"/feature/memory/profile/extraction/reasoning": "high",
"/feature/subjektiv/profile/extraction/reasoning": "medium"
}
}
]
@@ -0,0 +1,88 @@
# Editor-only shape of partial Profile values, used for completion and constraint
# analysis without materialization. Never used as defaults: omitted fields remain
# omitted until Manifest resolution.
let
Flag = { enabled = Bool; };
Model = {
ref = String; scheme = String; base_url = String; model_id = String;
auth = { kind = String; ref = String; file = String; };
capability = Unknown; context_window = Int; max_context_window = Int;
};
Extraction = {
# ReasoningControl accepts string effort or signed integer budget.
enabled = Bool; model = Model; reasoning = Unknown;
threshold = Int; worker_max_turns = Int;
};
# Profile scope accepts either a string intent or a table. Decodal 0.4 has
# no union range; keep this field opaque to constraint analysis and expose
# the table only as a completion hint. This shape is never materialized;
# actual scope forms, intent values and table fields are checked on save.
Scope = Unknown default { intent = String; deny_write = [...String]; symlink_policy = String; };
Profile = {
slug = String;
description = String;
worker = { mode = String; };
model = Model;
engine = {
instruction = String; language = String; reasoning = Unknown;
max_tokens = Int; max_turns = Int; temperature = Unknown; top_p = Unknown;
top_k = Int; stop_sequences = [...String]; tool_call_dispatch = String;
tool_output = { default_max_bytes = Int; per_tool = { ...Int }; };
file_upload = { max_bytes = Int; };
};
scope = Scope;
delegation_scope = Scope;
session = {
record_event_trace = Bool;
};
permissions = { default_action = String; rule = [...{ tool = String; pattern = String; action = String; }]; };
feature = {
task = Flag; web = Flag; image = Flag; sub_worker = Flag; flow = Flag;
worker = { enabled = Bool; direct_spawn = Bool; };
workspace_worker_discovery = Flag; objective = Flag; manage_workdir = Flag;
workdir_catalog = Flag; workspace_config = Flag; orchestration = Flag;
ticket = { enabled = Bool; authoring = Bool; thread = Bool; intake = Bool; workflow = Bool; };
merge_request = { show = Bool; open = Bool; review = Bool; readiness_check = Bool; complete = Bool; };
memory = {
enabled = Bool; staging_tools = Bool;
resident = { inject_summary = Bool; };
extraction = Extraction;
consolidation = { request_enabled = Bool; };
};
subjektiv = { enabled = Bool; consolidation_tools = Bool; extraction = Extraction; };
};
mcp = { stdio_server = [...{
name = String; command = String; args = [...String];
cwd = { kind = String; path = String; };
env = { inherit = [...String]; set = { ...Unknown }; };
}]; };
compaction = {
# Token settings (CompactionConfigPartial) and ratio helper fields.
# Threshold/context values accept integer tokens or numeric ratios.
kind = String; threshold = Unknown; request_threshold = Unknown;
worker_context_max_tokens = Unknown; request = Unknown; worker = Unknown;
prune_enabled = Bool; prune_protected_tokens = Int; prune_min_savings = Int;
retained_tokens = Int;
overview_target_tokens = Int; overview_warning_tokens = Int; overview_deadline_tokens = Int;
finish_warning_remaining_tokens = Int; final_reserve_tokens = Int;
worker_max_turns = Int; summary_target_tokens = Int; summary_max_tokens = Int;
auto_read_budget_tokens = Int; result_context_max_tokens = Int; model = Model;
# Supported token aliases remain authorable without rewriting recipes.
compact_threshold = Int; compact_request_threshold = Int; compact_retained_tokens = Int;
compact_worker_max_input_tokens = Int; compact_worker_max_turns = Int; compact_auto_read_budget = Int;
};
web = {
enabled = Bool; allow_private_addresses = Bool;
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
};
skills = { directories = [...String]; };
};
in {
profile = {
default_profile = String;
entries = [...{
selector = String; profile = Profile; label = String; description = String;
}];
};
}
+11
View File
@@ -0,0 +1,11 @@
{
profile = {
default_profile = String default "builtin:companion";
entries = [...{
selector = String;
profile = { ...Unknown };
label = String default "";
description = String default "";
}] default [];
};
}
+27
View File
@@ -0,0 +1,27 @@
slug = "job";
description = "Result-only Job policy; the Host grants attempt/result authority independently.";
model = { ref = "codex-oauth/gpt-5.6-luna"; };
engine = {
instruction = "internal.job_system";
reasoning = "medium";
max_tokens = 8192;
max_turns = 32;
};
session = { record_event_trace = false; };
feature = {
task = { enabled = false; };
memory = { enabled = false; staging_tools = false; };
subjektiv = { enabled = false; consolidation_tools = false; extraction = { enabled = false; }; };
web = { enabled = false; };
image = { enabled = false; };
sub_worker = { enabled = false; };
flow = { enabled = false; };
worker = { enabled = false; direct_spawn = false; };
workspace_worker_discovery = { enabled = false; };
objective = { enabled = false; };
manage_workdir = { enabled = false; };
workspace_config = { enabled = false; };
ticket = { enabled = false; authoring = false; thread = false; intake = false; workflow = false; };
merge_request = { show = false; open = false; review = false; readiness_check = false; complete = false; };
orchestration = { enabled = false; };
};
@@ -0,0 +1,8 @@
import "./job.dcdl" // {
slug = "standalone-subjektiv-consolidation";
description = "Subject consolidation policy; local Host grants a bounded batch, not body authority.";
engine = { instruction = "internal.subjektiv_memory_consolidation_system"; };
feature = {
subjektiv = { enabled = true; consolidation_tools = true; extraction = { enabled = false; }; };
};
}
@@ -0,0 +1,7 @@
import "./standalone.dcdl" // {
slug = "standalone-subjektiv";
description = "Standalone coding with an explicitly selected local Subject.";
feature = {
subjektiv = { enabled = true; extraction = { enabled = true; }; };
};
}

Some files were not shown because too many files have changed in this diff Show More