chore: merge develop into hare/develop
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "builtin-source"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
sha2.workspace = true
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -98,31 +98,47 @@ 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)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.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)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
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)
|
||||
.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)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result<JsValue, JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1455
-52
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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(®istration)`. `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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ impl FsStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_jsonl(
|
||||
pub(crate) fn parse_jsonl(
|
||||
content: &[u8],
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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, ®istration).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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ®istration).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
@@ -957,6 +957,7 @@ mod tests {
|
||||
language: "English".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
backend_job: None,
|
||||
})
|
||||
.unwrap();
|
||||
runtime.stop_worker(&worker.worker_ref, None).unwrap();
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 module = engine.add_root_source(path, path, source).map_err(|err| {
|
||||
ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
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
|
||||
.eval_module(module)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let data =
|
||||
engine
|
||||
.materialize(&value)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
decodal_data_to_json(&data)
|
||||
}
|
||||
})?;
|
||||
let value = engine
|
||||
.eval_module(module)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let data = engine
|
||||
.materialize(&value)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let json = 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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
validate_memory_lifecycle_targets(memory_profile, 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(),
|
||||
|
||||
@@ -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,13 +1657,9 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_client.clone(),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ready = backend_resident_source(&manifest, ready_client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded = ready.load().await;
|
||||
let SystemPromptContribution::Ready(loaded) = loaded else {
|
||||
panic!("subject resident context must be present");
|
||||
@@ -1722,13 +1679,9 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_empty_client,
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ready_empty = backend_resident_source(&manifest, ready_empty_client, test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
|
||||
panic!("empty surface must retain Subject behavior");
|
||||
};
|
||||
@@ -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,10 +1718,9 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let initial = source.load().await;
|
||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||
@@ -1838,10 +1790,9 @@ 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())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let SystemPromptContribution::Ready(failure) = source.load().await else {
|
||||
panic!("fetch failure must be explicit in resident context");
|
||||
@@ -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,30 +1837,22 @@ permission = "write"
|
||||
Some("policy-only surface must not load"),
|
||||
));
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(
|
||||
&policy_only,
|
||||
unattached_client.clone(),
|
||||
test_prompts()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
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())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = backend_resident_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = Arc::new(ArcSwap::from(
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
));
|
||||
@@ -1923,10 +1866,9 @@ 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())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = backend_resident_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||
source,
|
||||
Arc::new(ArcSwap::from(
|
||||
@@ -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,7 +655,11 @@ fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), H
|
||||
vec![call.evidence],
|
||||
item.proposal,
|
||||
) {
|
||||
failures.push(format!("{receipt_id}: {error}"));
|
||||
if !allow_pending
|
||||
|| !matches!(&error,ToolError::StructuredConflict {code,..} if code=="pending_commit")
|
||||
{
|
||||
failures.push(format!("{receipt_id}: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
@@ -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());
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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, ®istry, 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, ®istry)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { .. } => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+162
-62
@@ -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,64 +7482,62 @@ where
|
||||
create_if_missing,
|
||||
initial_diagnostic.to_string(),
|
||||
))?;
|
||||
let response = match self.workspace_client().execute_server_operation(
|
||||
WorkspaceServerOperation::SubjektivRecordSession(
|
||||
server_api::SubjektivRecordSessionRequest {
|
||||
session_id: session_id.to_string(),
|
||||
create_if_missing,
|
||||
},
|
||||
),
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let message = bounded_attribution_diagnostic(format!(
|
||||
"Session attribution transport outcome is unknown: {error}"
|
||||
));
|
||||
self.persist_subjektiv_session_attribution(
|
||||
incomplete_subjektiv_attribution_state(
|
||||
session_id,
|
||||
create_if_missing,
|
||||
message.clone(),
|
||||
),
|
||||
)?;
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
state: incomplete_subjektiv_attribution_label(create_if_missing),
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
if !response.is_success() {
|
||||
let message = bounded_attribution_diagnostic(format!(
|
||||
"Session attribution was rejected with HTTP {}: {}",
|
||||
response.status, response.body
|
||||
));
|
||||
let state = if create_if_missing {
|
||||
SubjektivSessionAttributionState::Failed {
|
||||
session_id,
|
||||
diagnostic: message.clone(),
|
||||
}
|
||||
} else {
|
||||
SubjektivSessionAttributionState::LegacyUnknown {
|
||||
session_id,
|
||||
diagnostic: message.clone(),
|
||||
}
|
||||
};
|
||||
self.persist_subjektiv_session_attribution(state)?;
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
state: if create_if_missing {
|
||||
"failed"
|
||||
} else {
|
||||
"legacy_unknown"
|
||||
},
|
||||
message,
|
||||
});
|
||||
}
|
||||
let output: server_api::SubjektivRecordSessionResponse =
|
||||
match serde_json::from_str(&response.body) {
|
||||
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 {
|
||||
session_id: session_id.to_string(),
|
||||
create_if_missing,
|
||||
},
|
||||
),
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let message = bounded_attribution_diagnostic(format!(
|
||||
"Session attribution response outcome is unknown: {error}"
|
||||
"Session attribution transport outcome is unknown: {error}"
|
||||
));
|
||||
self.persist_subjektiv_session_attribution(
|
||||
incomplete_subjektiv_attribution_state(
|
||||
@@ -7505,6 +7552,54 @@ where
|
||||
});
|
||||
}
|
||||
};
|
||||
if !response.is_success() {
|
||||
let message = bounded_attribution_diagnostic(format!(
|
||||
"Session attribution was rejected with HTTP {}: {}",
|
||||
response.status, response.body
|
||||
));
|
||||
let state = if create_if_missing {
|
||||
SubjektivSessionAttributionState::Failed {
|
||||
session_id,
|
||||
diagnostic: message.clone(),
|
||||
}
|
||||
} else {
|
||||
SubjektivSessionAttributionState::LegacyUnknown {
|
||||
session_id,
|
||||
diagnostic: message.clone(),
|
||||
}
|
||||
};
|
||||
self.persist_subjektiv_session_attribution(state)?;
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
state: if create_if_missing {
|
||||
"failed"
|
||||
} else {
|
||||
"legacy_unknown"
|
||||
},
|
||||
message,
|
||||
});
|
||||
}
|
||||
let output: server_api::SubjektivRecordSessionResponse =
|
||||
match serde_json::from_str(&response.body) {
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
let message = bounded_attribution_diagnostic(format!(
|
||||
"Session attribution response outcome is unknown: {error}"
|
||||
));
|
||||
self.persist_subjektiv_session_attribution(
|
||||
incomplete_subjektiv_attribution_state(
|
||||
session_id,
|
||||
create_if_missing,
|
||||
message.clone(),
|
||||
),
|
||||
)?;
|
||||
return Err(WorkerError::SubjektivSessionAttribution {
|
||||
state: incomplete_subjektiv_attribution_label(create_if_missing),
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
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 {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("literal /prepare()".into()),
|
||||
load_count: Arc::default(),
|
||||
behavior_revision: Some(behavior_revision.clone()),
|
||||
}),
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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.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<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,
|
||||
}
|
||||
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}"
|
||||
)));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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
@@ -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,46 +193,16 @@ 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")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selector_for_workspace_candidate(
|
||||
projection: &ProfileConfigProjection,
|
||||
profile: &str,
|
||||
@@ -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"; }]; }; }"#,
|
||||
)
|
||||
.unwrap(),
|
||||
]);
|
||||
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("missing from the active config revision")
|
||||
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()
|
||||
.unwrap();
|
||||
assert!(
|
||||
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!({}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+155
-1611
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(
|
||||
&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(),
|
||||
&request.job_id,
|
||||
&attempt.attempt_id,
|
||||
)
|
||||
.map_err(|e| Error::InvalidInput(format!("consolidation surface result mismatch: {e}")))
|
||||
subjektiv::job::validate_result(
|
||||
&store,
|
||||
&grant.subject_id,
|
||||
&grant.candidate_ids,
|
||||
&request.job_id,
|
||||
&attempt.attempt_id,
|
||||
value,
|
||||
)
|
||||
.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
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+213
-6
@@ -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(
|
||||
connection_resolver,
|
||||
CliCommand::DefaultTui,
|
||||
target_selection,
|
||||
&workspace_root,
|
||||
)?;
|
||||
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,
|
||||
..
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user