feat(subjektiv): share domain and add explicit standalone Subject hosts

This commit is contained in:
2026-10-07 01:52:06 +09:00
parent 13055d7ee1
commit 24ae6288a5
65 changed files with 18273 additions and 9785 deletions
Generated
+35
View File
@@ -1396,6 +1396,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "feature-storage"
version = "0.1.0"
dependencies = [
"rusqlite",
"tempfile",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -4634,15 +4644,20 @@ dependencies = [
"agen",
"async-trait",
"client",
"feature-storage",
"fs4",
"futures",
"job",
"libc",
"manifest",
"memory",
"protocol",
"rusqlite",
"serde",
"serde_json",
"server-api",
"session-store",
"subjektiv",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -4719,6 +4734,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subjektiv"
version = "0.1.0"
dependencies = [
"chrono",
"feature-storage",
"manifest",
"memory",
"rusqlite",
"serde",
"serde_json",
"server-api",
"tempfile",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6583,6 +6615,7 @@ dependencies = [
"server-api",
"session-analytics",
"session-store",
"standalone",
"tempfile",
"ticket",
"tokio",
@@ -6627,6 +6660,7 @@ dependencies = [
"axum",
"chrono",
"config-source",
"feature-storage",
"flow",
"futures",
"job",
@@ -6646,6 +6680,7 @@ dependencies = [
"session-store",
"sha2 0.11.0",
"ssh-key",
"subjektiv",
"tempfile",
"thiserror 2.0.18",
"ticket",
+6
View File
@@ -30,6 +30,8 @@ members = [
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
@@ -66,6 +68,8 @@ default-members = [
"crates/workdir",
"crates/tui",
"crates/memory",
"crates/feature-storage",
"crates/subjektiv",
"crates/ticket",
"crates/merge-request",
"crates/project-record",
@@ -93,6 +97,8 @@ manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
feature-storage = { path = "crates/feature-storage" }
subjektiv = { path = "crates/subjektiv" }
merge-request = { path = "crates/merge-request" }
ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "feature-storage"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
rusqlite.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+28
View File
@@ -0,0 +1,28 @@
# feature-storage
Neutral Host-owned SQLite lifecycle manager for trusted Features. Owns placement,
WAL/connection settings, transaction serialization, migrations, snapshots,
restore/delete fencing, and shutdown. Features receive a managed
`FeatureDatabase` after registration. Worker/model input never selects a database
path or receives SQL access.
Use `FeatureStorage::new(trusted_root)`, `register(FeatureRegistration)`, and
`scope(host_issued_scope_id).open(&registration)`. `ScopedFeatureStorage` and
`FeatureDatabase::scope_id()` are neutral storage isolation APIs: a local Host's
persisted random scope does not create a Workspace record or imply Backend
membership. Existing placement `<root>/<scope>/features/<feature>.sqlite` and
snapshot `workspace=` keys remain unchanged for physical compatibility.
`WorkspaceFeatureStorage`, `workspace()`, `workspace_id()`, and
`for_server_database()` are Backend compatibility aliases/helpers. They do not
introduce a Server dependency. The Host is responsible for state-root privacy,
symlink/path validation, and external execution leases before constructing this
trusted manager.
`configure_connection(&rusqlite::Connection)` is reusable by other Host stores
(such as JobStore). It sets the five-second busy timeout, foreign keys ON, WAL,
and synchronous FULL before migrations/transactions. The Feature manager uses
this same helper; WAL lock-contention retries stay within the busy timeout.
Dependencies: `rusqlite` (existing workspace backup support), `thiserror`, `uuid`;
tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+20
View File
@@ -59,6 +59,26 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
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",
+19 -4
View File
@@ -189,7 +189,9 @@ pub fn validate_profile_execution_target(
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.subjektiv.profile.enabled {
// Subject policy is Host-neutral. Only an already bound Backend snapshot
// requires Workspace authority; a local Host supplies its own live capability.
if feature.subjektiv.workspace_settings.is_some() {
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
}
if feature.merge_request.show
@@ -1257,11 +1259,21 @@ mod tests {
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "role-worker");
if entry.name == "job" {
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, "internal.job_system");
assert_eq!(
resolved.manifest.engine.instruction,
if entry.name == "job" {
"internal.job_system"
} else {
"internal.subjektiv_memory_consolidation_system"
}
);
continue;
}
let compaction = resolved.manifest.compaction.as_ref().unwrap();
@@ -1669,7 +1681,10 @@ mod tests {
panic!("unexpected error: {error}");
};
assert_eq!(target, ProfileExecutionTarget::Standalone);
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
assert!(
!requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv),
"subject policy alone is no longer Backend authority"
);
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
+26
View File
@@ -50,6 +50,9 @@ pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
/// Stamped only by the operator-owned standalone in-process transport.
/// This is not a Backend Account or authority accepted from wire fields.
LocalOperator,
Account {
account_id: String,
},
@@ -86,6 +89,7 @@ impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
Self::UntrustedWire => "untrusted-wire".into(),
Self::LocalOperator => "local-operator".into(),
Self::Account { account_id } => format!("account:{account_id}"),
Self::Worker {
runtime_id,
@@ -2175,6 +2179,28 @@ mod tests {
);
}
#[test]
fn local_operator_source_cannot_be_granted_by_serialized_input() {
let method = Method::SubmitTracked {
input: vec![Segment::text("forged local operator")],
submission_request_id: "local-forged".into(),
source: AuthenticatedInputSource::LocalOperator,
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(matches!(
decoded,
Method::SubmitTracked {
source: AuthenticatedInputSource::UntrustedWire,
..
}
));
assert_ne!(
AuthenticatedInputSource::LocalOperator.namespace(),
AuthenticatedInputSource::UntrustedWire.namespace()
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
+1 -1
View File
@@ -211,7 +211,7 @@ impl FsStore {
}
}
fn parse_jsonl(
pub(crate) fn parse_jsonl(
content: &[u8],
session_id: SessionId,
segment_id: SegmentId,
+2
View File
@@ -40,6 +40,8 @@ pub struct LoggedWorkerSubject {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedSessionHistoryOrigin {
/// Human input verified by the standalone Host, without a Backend Account.
LocalHumanInput,
HumanInput {
account_id: String,
},
+167 -3
View File
@@ -208,6 +208,172 @@ pub fn read_session_public_index(
let segment_ids = store
.list_segments_read_only(session_id)
.map_err(map_store_error)?;
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)
})
}
/// Strict committed public projection for the standalone FsStore layout.
/// Uses the same lineage, commit filtering and entry projection as retained
/// WorkerSessionStore observation. No writes, migrations, trace/artifact reads,
/// access-time updates or directory creation occur here.
pub fn read_fs_session_public_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
read_fs_session_evidence_index(sessions_root, session_id, limits).map(|value| value.index)
}
/// Producer-compatible numeric hints for stable public evidence references.
/// Both committed annotated-history and filtered public-snapshot capture are
/// valid producers. No hidden history content is exposed by this metadata.
pub struct FsSessionEvidenceIndex {
pub index: SessionPublicIndex,
pub ranges: HashMap<(String, String), Vec<[u64; 2]>>,
pub has_open_run: bool,
}
pub fn read_fs_session_evidence_index(
sessions_root: &Path,
session_id: SessionId,
limits: SessionPublicIndexLimits,
) -> Result<FsSessionEvidenceIndex, SessionPublicIndexReadError> {
if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let session_dir = sessions_root.join(session_id.to_string());
// IDs are typed and every storage component is observationally validated;
// a known Session ID never authorizes following arbitrary local symlinks.
let mut prefix = std::path::PathBuf::new();
for part in session_dir.components() {
prefix.push(part);
let metadata =
std::fs::symlink_metadata(&prefix).map_err(|_| SessionPublicIndexReadError::Missing)?;
if metadata.file_type().is_symlink() {
return Err(SessionPublicIndexReadError::Storage);
}
}
let mut segment_ids = Vec::new();
for entry in
std::fs::read_dir(&session_dir).map_err(|_| SessionPublicIndexReadError::Missing)?
{
let entry = entry.map_err(|_| SessionPublicIndexReadError::Storage)?;
let path = entry.path();
if path.extension().and_then(|v| v.to_str()) != Some("jsonl") {
continue;
}
let Some(id) = path
.file_stem()
.and_then(|v| v.to_str())
.and_then(|v| v.parse::<SegmentId>().ok())
else {
continue;
};
if !entry
.file_type()
.map_err(|_| SessionPublicIndexReadError::Storage)?
.is_file()
{
return Err(SessionPublicIndexReadError::Storage);
}
segment_ids.push(id);
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
}
segment_ids.sort();
if segment_ids.is_empty() {
return Err(SessionPublicIndexReadError::Missing);
}
let mut ranges = HashMap::<(String, String), Vec<[u64; 2]>>::new();
let mut has_open_run = false;
let index =
project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| {
let path = session_dir.join(format!("{segment_id}.jsonl"));
let bytes = crate::read_without_atime_bounded(&path, remaining)
.map_err(|_| SessionPublicIndexReadError::Storage)?;
if bytes.len() as u64 > remaining {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
let entries = crate::FsStore::parse_jsonl(&bytes, session_id, segment_id)
.map_err(map_store_error)?;
let mut open = false;
for entry in &entries {
match entry {
LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => open = true,
LogEntry::RunCompleted { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }
| LogEntry::PausedTurnAbandoned { .. } => open = false,
_ => {}
}
}
has_open_run |= open;
let committed = committed_conversation_records(&entries);
let state = crate::segment_log::collect_state(&committed);
for (position, entry) in state.annotated_history.iter().enumerate() {
ranges
.entry((
segment_id.to_string(),
format!("E{}", entry.metadata.entry_id.0),
))
.or_default()
.push([position as u64, position as u64]);
}
let snapshot = crate::public_snapshot::project_session_snapshot_for_segment(
session_id,
Some(segment_id),
&committed,
);
let mut position = 0_u64;
for entry in snapshot.entries {
if matches!(
entry.data,
SessionSnapshotEntryData::UserInput { .. }
| SessionSnapshotEntryData::Message { .. }
| SessionSnapshotEntryData::ToolCall { .. }
| SessionSnapshotEntryData::ToolResult { .. }
) {
ranges
.entry((segment_id.to_string(), format!("E{}", entry.entry_id)))
.or_default()
.push([position, position]);
position += 1;
}
}
Ok((entries, bytes.len() as u64))
})?;
// Intersect with the strict public projection, never turn knowledge of a
// hidden/reasoning history identity into a public evidence reference.
let public = index
.segments
.iter()
.flat_map(|seg| {
seg.entries
.iter()
.map(|entry| (seg.segment_id.clone(), entry.entry_ref.clone()))
})
.collect::<HashSet<_>>();
ranges.retain(|key, _| public.contains(key));
Ok(FsSessionEvidenceIndex {
index,
ranges,
has_open_run,
})
}
fn project_public_index_records(
session_id: SessionId,
segment_ids: Vec<SegmentId>,
limits: SessionPublicIndexLimits,
mut read_segment: impl FnMut(
SegmentId,
u64,
) -> Result<(Vec<LogEntry>, u64), SessionPublicIndexReadError>,
) -> Result<SessionPublicIndex, SessionPublicIndexReadError> {
if segment_ids.len() > limits.max_segments {
return Err(SessionPublicIndexReadError::ResourceLimit);
}
@@ -224,9 +390,7 @@ pub fn read_session_public_index(
.max_bytes
.checked_sub(scanned_bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
let (entries, bytes) = store
.read_all_read_only_bounded(session_id, segment_id, remaining)
.map_err(map_store_error)?;
let (entries, bytes) = read_segment(segment_id, remaining)?;
scanned_bytes = scanned_bytes
.checked_add(bytes)
.ok_or(SessionPublicIndexReadError::ResourceLimit)?;
+2 -1
View File
@@ -1654,7 +1654,8 @@ fn legacy_entry_id(
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
match origin {
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::HumanInput { .. }
| LoggedSessionHistoryOrigin::LocalHumanInput => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction
+6
View File
@@ -9,6 +9,12 @@ 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
+219 -10
View File
@@ -44,12 +44,15 @@ pub struct StandaloneHost {
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")]
@@ -107,16 +110,53 @@ 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 jobs = match crate::jobs::StandaloneJobs::open(
&store.jobs_path(worker_id),
launch.cwd.clone(),
) {
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);
@@ -124,6 +164,12 @@ impl StandaloneHost {
}
};
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.
@@ -156,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) => {
@@ -174,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(_) => {
@@ -195,6 +248,7 @@ impl StandaloneHost {
record,
allocation.into_lease(),
jobs,
subject,
))
}
@@ -230,11 +284,50 @@ impl StandaloneHost {
let lease = store
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
.map_err(classify_store_startup_error)?;
let jobs = crate::jobs::StandaloneJobs::open(
&store.jobs_path(worker_id),
record.cwd.canonical_path.clone(),
)
.map_err(|_| StandaloneStartupError::StateStore)?;
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();
@@ -259,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);
}
@@ -266,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,
@@ -290,6 +389,7 @@ impl StandaloneHost {
record,
lease,
jobs,
subject,
))
}
@@ -300,7 +400,11 @@ impl StandaloneHost {
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),
@@ -310,6 +414,7 @@ impl StandaloneHost {
record,
lease: Some(lease),
jobs,
subject,
}
}
@@ -388,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()
@@ -397,6 +508,9 @@ 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();
}
@@ -413,6 +527,51 @@ impl Drop for StandaloneHost {
}
}
// 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,
@@ -438,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
{
@@ -578,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 =>
@@ -587,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:?}"),
}
}
}
}
+73 -7
View File
@@ -6,7 +6,6 @@
//! authority. The service owns the connection mutex and live-resource cleanup.
use std::path::Path;
use std::time::Duration;
use job::{JobRequest, JobResultSubmission};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
@@ -14,7 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_VERSION: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -57,6 +56,8 @@ impl JobAttemptState {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobSnapshot {
pub request: JobRequest,
/// Durable Host-issued domain delegation, not model input or Profile identity.
pub domain_grant: Option<Value>,
pub state: JobState,
pub attempt: JobAttempt,
pub result: Option<Value>,
@@ -112,6 +113,8 @@ pub struct JobAttempt {
pub enum JobStoreError {
#[error("Job database: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error(transparent)]
Storage(#[from] feature_storage::FeatureStorageError),
#[error("Job JSON: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
@@ -151,16 +154,30 @@ impl JobStore {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
}
let mut conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(5))?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "FULL")?;
feature_storage::configure_connection(&conn)?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let version: i64 = tx.pragma_query_value(None, "user_version", |row| row.get(0))?;
match version {
0 => {
tx.execute_batch(SCHEMA)?;
tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
1 => {
tx.execute_batch(GRANT_SCHEMA)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
}
SCHEMA_VERSION => {}
@@ -186,7 +203,17 @@ impl JobStore {
}
pub(crate) fn reserve(&mut self, request: JobRequest) -> Result<JobSnapshot, JobStoreError> {
self.reserve_granted(request, None)
}
pub(crate) fn reserve_granted(
&mut self,
request: JobRequest,
grant: Option<Value>,
) -> Result<JobSnapshot, JobStoreError> {
request.validate()?;
if let Some(value) = &grant {
job::result_digest(value, job::ABSOLUTE_MAX_RESULT_BYTES)?;
}
let fingerprint = request.fingerprint()?;
let request_json = serde_json::to_string(&request)?;
let tx = self
@@ -198,7 +225,10 @@ impl JobStore {
[&request.job_id],
|row| row.get(0),
)?;
if stored != fingerprint || snapshot.request != request {
if stored != fingerprint
|| snapshot.request != request
|| snapshot.domain_grant != grant
{
return Err(JobStoreError::IntentConflict(request.job_id));
}
tx.commit()?;
@@ -219,6 +249,12 @@ impl JobStore {
],
)?;
insert_attempt(&tx, &request, 1)?;
if let Some(grant) = grant {
tx.execute(
"INSERT INTO job_domain_grants(job_id, grant_json) VALUES (?1, ?2)",
params![request.job_id, serde_json::to_string(&grant)?],
)?;
}
let snapshot = require_snapshot(&tx, &request.job_id)?;
tx.commit()?;
Ok(snapshot)
@@ -255,6 +291,16 @@ impl JobStore {
require_snapshot(&self.conn, job_id)
}
pub(crate) fn unacknowledged(&self) -> Result<Vec<JobSnapshot>, JobStoreError> {
let mut statement = self.conn.prepare("SELECT job_id FROM job_intents WHERE state = 'completed' AND acknowledged = 0 ORDER BY rowid")?;
let ids = statement
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
ids.into_iter()
.map(|id| require_snapshot(&self.conn, &id))
.collect()
}
/// Only unstarted current attempts are eligible for service startup/resume.
pub(crate) fn pending(&self) -> Result<Vec<JobSnapshot>, JobStoreError> {
let mut statement = self.conn.prepare(
@@ -570,6 +616,15 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result<Option<JobSnapshot>,
};
Ok(JobSnapshot {
request: serde_json::from_str(&request)?,
domain_grant: conn
.query_row(
"SELECT grant_json FROM job_domain_grants WHERE job_id = ?1",
[job_id],
|row| row.get::<_, String>(0),
)
.optional()?
.map(|s| serde_json::from_str(&s))
.transpose()?,
state,
acknowledged,
attempt: JobAttempt {
@@ -585,6 +640,17 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result<Option<JobSnapshot>,
.transpose()
}
const GRANT_SCHEMA: &str = "
CREATE TABLE job_domain_grants (
job_id TEXT PRIMARY KEY NOT NULL REFERENCES job_intents(job_id),
grant_json TEXT NOT NULL
);
CREATE TRIGGER job_grant_immutable BEFORE UPDATE ON job_domain_grants
BEGIN SELECT RAISE(ABORT, 'immutable Job domain grant'); END;
CREATE TRIGGER job_grant_retained BEFORE DELETE ON job_domain_grants
BEGIN SELECT RAISE(ABORT, 'retained Job domain grant'); END;
";
const SCHEMA: &str = "
CREATE TABLE job_intents (
job_id TEXT PRIMARY KEY NOT NULL,
+119 -4
View File
@@ -27,6 +27,17 @@ pub enum JobServiceError {
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>>>,
@@ -48,6 +59,8 @@ pub struct StandaloneJobs {
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 {
@@ -60,9 +73,43 @@ impl StandaloneJobs {
})),
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>,
@@ -91,6 +138,10 @@ impl StandaloneJobs {
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> {
@@ -154,9 +205,40 @@ impl StandaloneJobs {
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 prepared = worker::job::prepare_job(&snapshot.request, &self.cwd, client)
.map_err(|error| JobServiceError::Configuration(error.to_string()))?;
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);
@@ -209,13 +291,12 @@ impl StandaloneJobs {
let store = self.store.clone();
let id = job_id.to_string();
let attempt_id = snapshot.attempt.attempt_id.clone();
let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds));
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: tokio::time::Instant::now() + timeout,
deadline,
});
let (cancel, mut cancellation) = watch::channel(false);
let (complete_tx, complete) = watch::channel(None);
@@ -341,6 +422,11 @@ impl StandaloneJobs {
// 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)
}
}
@@ -391,3 +477,32 @@ impl worker::job::JobResultSink for BoundResultSink {
.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(())
}
}
+345
View File
@@ -1,4 +1,10 @@
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 {
@@ -32,6 +38,345 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission {
}
}
#[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();
+32
View File
@@ -15,6 +15,7 @@ pub struct StandaloneLaunchConfig {
pub state_dir: PathBuf,
pub profile: ProfileSelector,
pub worker_name: String,
pub subject_id: Option<String>,
}
pub struct ResolvedStandaloneLaunch {
@@ -22,6 +23,7 @@ pub struct ResolvedStandaloneLaunch {
pub state_dir: PathBuf,
pub profile: ResolvedProfile,
pub prompt_catalog: PromptCatalogSource,
pub subject_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
@@ -32,6 +34,10 @@ pub enum StandaloneLaunchError {
PathProfileUnsupported,
#[error("the standalone profile could not be resolved")]
ProfileResolutionFailed,
#[error(
"local Subject selection requires an enabled, non-consolidator subjektiv Profile and canonical Subject id"
)]
InvalidSubjectPolicy,
}
impl StandaloneLaunchConfig {
@@ -46,9 +52,15 @@ impl StandaloneLaunchConfig {
state_dir: state_dir.into(),
profile,
worker_name: worker_name.into(),
subject_id: None,
}
}
pub fn with_subject(mut self, subject_id: String) -> Self {
self.subject_id = Some(subject_id);
self
}
/// Resolve only built-in/XDG profile authority and bind standalone scope
/// to the canonical process cwd. Repository-local profile discovery is
/// deliberately not part of this path.
@@ -74,12 +86,32 @@ impl StandaloneLaunchConfig {
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
if let Some(id) = &self.subject_id {
if !crate::subjektiv::valid_subject_id(id)
|| !profile.manifest.feature.subjektiv.profile.enabled
|| profile
.manifest
.feature
.subjektiv
.profile
.consolidation_tools
|| profile
.manifest
.feature
.subjektiv
.workspace_settings
.is_some()
{
return Err(StandaloneLaunchError::InvalidSubjectPolicy);
}
}
Ok(ResolvedStandaloneLaunch {
cwd,
state_dir: self.state_dir,
profile,
prompt_catalog: PromptCatalogSource::builtins_only(),
subject_id: self.subject_id,
})
}
}
+2
View File
@@ -9,6 +9,8 @@ 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};
+34
View File
@@ -87,6 +87,9 @@ pub struct StandaloneWorkerRecord {
pub storage_key: String,
pub cwd: StandaloneCwdIdentity,
pub manifest: WorkerManifest,
/// Original explicit local Subject/storage binding; never inferred from policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
pub active_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_segment_id: Option<SegmentId>,
@@ -127,6 +130,16 @@ impl StandaloneWorkerStore {
Ok(Self { root })
}
/// Observational constructor: never creates a missing root or repairs state.
pub fn open_existing(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
let root = root.into();
let metadata = fs::symlink_metadata(&root).map_err(StandaloneStoreError::Io)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(StandaloneStoreError::NotDirectory);
}
Ok(Self { root })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
@@ -158,6 +171,25 @@ impl StandaloneWorkerStore {
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
self.commit_created_connected(
allocation,
manifest,
storage_key,
active_session_id,
active_segment_id,
None,
)
}
pub(crate) fn commit_created_connected(
&self,
allocation: &StandaloneWorkerAllocation,
manifest: WorkerManifest,
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
subject: Option<crate::subjektiv::StandaloneSubjectBinding>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let now = now_unix_ms()?;
let record = StandaloneWorkerRecord {
@@ -168,6 +200,7 @@ impl StandaloneWorkerStore {
storage_key,
cwd: allocation.cwd.clone(),
manifest,
subject,
active_session_id,
active_segment_id,
status: StandaloneWorkerStatus::Active,
@@ -793,6 +826,7 @@ permission = "write"
inode: None,
},
manifest,
subject: None,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
use super::*;
#[test]
fn catalog_reopen_preserves_random_scope_subject_and_behavior() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("state");
let catalog = StandaloneSubjects::open(&root).unwrap();
let subject = catalog
.create(
"continuing-owner",
Some("Keep user behavior separate from Memory."),
)
.unwrap();
let scope = catalog.scope_id().to_owned();
assert!(uuid::Uuid::parse_str(&scope).is_ok());
assert!(uuid::Uuid::parse_str(subject.id.strip_prefix("subject-").unwrap()).is_ok());
assert_ne!(scope, subject.id);
drop(catalog);
let reopened = StandaloneSubjects::open_existing(&root).unwrap();
assert_eq!(reopened.scope_id(), scope);
assert_eq!(reopened.list().unwrap(), vec![subject]);
}
#[test]
fn implicit_open_does_not_create_subject_storage() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("missing");
assert!(StandaloneSubjects::open_existing(&root).is_err());
assert!(!root.exists());
assert!(StandaloneSubjects::open_existing(temp.path()).is_err());
assert!(!temp.path().join("subjektiv").exists());
}
#[test]
fn subject_kernel_lease_is_not_worker_or_profile_scoped() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let a = catalog.create("a", None).unwrap();
let b = catalog.create("b", None).unwrap();
let mut first = catalog
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
assert!(matches!(
StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7()),
Err(SubjectError::Active(_))
));
let mut second = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&b.id, protocol::WorkerId::now_v7())
.unwrap();
first.close().unwrap();
let mut replacement = StandaloneSubjects::open_existing(&temp.path().join("state"))
.unwrap()
.connect(&a.id, protocol::WorkerId::now_v7())
.unwrap();
replacement.close().unwrap();
second.close().unwrap();
}
#[test]
fn closed_capabilities_and_foreign_worker_context_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let subject = catalog.create("owner", None).unwrap();
let id = protocol::WorkerId::now_v7();
let mut connection = catalog.connect(&subject.id, id).unwrap();
let port = connection.host.clone();
let foreign = SubjektivHostContext {
worker_id: protocol::WorkerId::now_v7().to_string(),
session_id: session_store::SessionId::now_v7().to_string(),
};
let read = SubjektivMemoryBackendOperation::ResidentContext(
memory::backend::MemoryResidentSummaryOperation {},
);
assert!(port.memory(&foreign, read.clone()).is_err());
let owner = SubjektivHostContext {
worker_id: format!("standalone-{id}"),
session_id: session_store::SessionId::now_v7().to_string(),
};
assert!(port.memory(&owner, read.clone()).is_ok());
assert!(
port.record_session(&owner, false).is_err(),
"unattributed Session is not read authority"
);
connection.close().unwrap();
assert!(port.memory(&owner, read).is_err());
}
#[cfg(unix)]
#[test]
fn scope_rejects_symlinks_and_non_private_roots() {
use std::os::unix::fs::{PermissionsExt, symlink};
let temp = tempfile::tempdir().unwrap();
let alias = temp.path().join("alias");
let real = temp.path().join("real");
fs::create_dir(&real).unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap();
symlink(&real, &alias).unwrap();
assert!(StandaloneSubjects::open(&alias).is_err());
let public = temp.path().join("public");
fs::create_dir(&public).unwrap();
fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap();
assert!(StandaloneSubjects::open(&public).is_err());
let catalog = StandaloneSubjects::open(&real).unwrap();
drop(catalog);
let scope = real.join("subjektiv/scope.json");
let original = fs::read(&scope).unwrap();
fs::remove_file(&scope).unwrap();
let foreign = temp.path().join("foreign.json");
fs::write(&foreign, &original).unwrap();
symlink(&foreign, &scope).unwrap();
assert!(StandaloneSubjects::open_existing(&real).is_err());
assert_eq!(fs::read(&foreign).unwrap(), original);
}
#[test]
fn different_local_roots_do_not_authorize_shared_subject_ids() {
let temp = tempfile::tempdir().unwrap();
let first = StandaloneSubjects::open(temp.path().join("first")).unwrap();
let subject = first.create("owner", None).unwrap();
let other = StandaloneSubjects::open(temp.path().join("other")).unwrap();
assert_ne!(first.scope_id(), other.scope_id());
assert!(
other
.connect(&subject.id, protocol::WorkerId::now_v7())
.is_err()
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "subjektiv"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
feature-storage = { path = "../feature-storage" }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
memory.workspace = true
manifest.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
server-api.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
tempfile.workspace = true
+76
View File
@@ -0,0 +1,76 @@
# subjektiv
Shared Subject-scoped Memory domain and model-facing operations. This crate has
no Workspace Server dependency. `server-api` is the existing transport-neutral
request/response contract crate, not an authentication or Server implementation.
## Storage scope
The trusted Host creates `feature_storage::FeatureStorage`, selects
`FeatureStorage::scope(scope_id)`, registers `REGISTRATION` (or uses
`SubjektivStore::register`), and opens `SubjektivStore::open`. A trusted managed
`FeatureDatabase` can also be opened with `SubjektivStore::from_database`.
`scope_id` identifies the physical storage sharing boundary. Backend uses its
existing Workspace ID. Standalone uses a persisted random local storage-scope
identity under its operator-managed state directory, **not a fake Workspace**.
Subject IDs are independently issued random domain identities. An ID is a lookup
key, never an authorization credential.
Existing SQL schema and migrations are retained. In particular,
`store_scope.workspace_id` still stores the physical storage scope, under its
historical column name. Existing Backend placement and snapshot manifest keys
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
to be checked against the physical scope; local provenance need not invent a
Workspace value. `workspace_id()` is a Backend compatibility alias for
`scope_id()`.
## Host operation boundary
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
nested provenance projections, immutable revision reads, proposal validation,
explicit staging/receipt rules, resident context, candidate decisions and
surface generation/publication/failure projections.
The Host context is not serializable or model input. Trusted Rust Hosts construct
it only after validating their external execution authority:
- `HostOperationContext::validated_body` binds one Subject and optionally an
existing immutable `SubjectSessionAttribution`. Explicit staging requires the
request Session to match that attribution.
- `HostOperationContext::validated_consolidation` binds one Subject, an optional
immutable candidate batch, and an optional `JobAttemptBinding`. Attenuated Jobs
must supply `Some(batch)`, including an empty batch; `None` is legacy non-Job
Host consolidation authority. Body-only operations are unavailable to
consolidation contexts, and candidate reads/decisions cannot escape the batch.
These constructors validate store/domain bindings, not external authentication.
Backend retains authenticated Runtime/Worker identity, current singleton and
live Job attempt checks, candidate grant checks, and public Session validation.
Local Hosts retain exclusive Subject execution leases, generic Job admission,
Worker/Session ownership, and committed public Session evidence validation.
Before explicit staging, the Host resolves evidence using its committed public
Session projection; the common API does not inspect Session files, hidden
reasoning, system prompts, or arbitrary paths. Lower-level repository/projection
functions are trusted Rust APIs, not model capabilities.
## Common consolidation Job rules
`job::bounded_candidate_batch(ids, max_result_bytes)` selects the exact T-704
ordered-prefix budget (100 candidates maximum, 2,048-byte envelope, twice each
JSON-escaped ID size plus 128 bytes per disposition).
`job::consolidation_required(count, bytes, force, availability)` owns the shared
trigger thresholds and empty-ready/surface-retry rules.
`job::validate_result(store, subject_id, batch, job_id, attempt_id, value)` checks
the strict result shape, exact admitted Subject/batch, durable dispositions, and
the persisted terminal surface outcome from that attempt. After validation,
`job::with_dispositions(store, subject_id, batch, value)` adds the deterministic
compact disposition projection. Hosts retain Job result binding/admission; these
helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
trusted-domain methods for both Backend and local Hosts.
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
//! Trusted Host operation context, never deserialized from model input.
use super::{OperationError, OperationResult, subjektiv_store_error};
use crate::{SubjectSessionAttribution, SubjektivStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobAttemptBinding {
pub job_id: String,
pub attempt_id: String,
}
#[derive(Debug, Clone)]
enum Authority {
Body {
attribution: Option<SubjectSessionAttribution>,
},
Consolidation {
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
},
}
/// A capability issued by trusted Rust Host code after authentication/lease,
/// delegation, and committed Session evidence checks. Not a request DTO.
/// Constructors check domain binding, not external identity or execution leases.
/// Backend must retain its authenticated Worker and active Job grant checks;
/// standalone must hold the selected Subject lease and attenuate Job batches.
#[derive(Debug, Clone)]
pub struct HostOperationContext {
scope_id: String,
subject_id: String,
authority: Authority,
}
impl HostOperationContext {
/// The Host has validated body execution authority. Attribution, when supplied,
/// must already be immutable history in this store. Before StageExplicit the
/// Host must resolve/validate all evidence against committed public Session
/// entries; neither this constructor nor the operation reads Session files.
pub fn validated_body(
store: &SubjektivStore,
subject_id: &str,
attribution: Option<SubjectSessionAttribution>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(record) = &attribution {
if record.subject_id != subject_id
|| store
.session_attribution(&record.session_id)
.map_err(subjektiv_store_error)?
.as_ref()
!= Some(record)
{
return Err(OperationError::PermissionDenied(
"Session attribution does not match the Host subject".into(),
));
}
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Body { attribution },
})
}
/// The Host has validated consolidation authority and (for a Job) its active
/// attempt. `Some(candidate_ids)` is the immutable admitted batch; no candidate
/// outside it is accessible. `None` retains Backend's non-Job consolidation
/// authority, and must not be issued to an attenuated Job.
pub fn validated_consolidation(
store: &SubjektivStore,
subject_id: &str,
candidate_ids: Option<Vec<String>>,
job: Option<JobAttemptBinding>,
) -> OperationResult<Self> {
Self::require_subject(store, subject_id)?;
if let Some(ids) = &candidate_ids {
for id in ids {
if store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"candidate_not_found: {id}"
)));
}
}
}
if job.as_ref().is_some_and(|binding| {
binding.job_id.trim().is_empty() || binding.attempt_id.trim().is_empty()
}) {
return Err(OperationError::InvalidInput(
"Job attempt binding must not be empty".into(),
));
}
Ok(Self {
scope_id: store.scope_id().into(),
subject_id: subject_id.into(),
authority: Authority::Consolidation { candidate_ids, job },
})
}
fn require_subject(store: &SubjektivStore, id: &str) -> OperationResult<()> {
store
.subject(id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| OperationError::SubjectNotFound(id.into()))?;
Ok(())
}
pub fn subject_id(&self) -> &str {
&self.subject_id
}
pub(super) fn require_store(&self, store: &SubjektivStore) -> OperationResult<()> {
if self.scope_id != store.scope_id() {
return Err(OperationError::PermissionDenied(
"Host operation context belongs to another storage scope".into(),
));
}
Ok(())
}
pub(super) fn body(&self) -> OperationResult<Option<&SubjectSessionAttribution>> {
match &self.authority {
Authority::Body { attribution } => Ok(attribution.as_ref()),
_ => Err(OperationError::PermissionDenied(
"operation requires Subject body authority".into(),
)),
}
}
pub(super) fn consolidation(
&self,
) -> OperationResult<(Option<&[String]>, Option<&JobAttemptBinding>)> {
match &self.authority {
Authority::Consolidation { candidate_ids, job } => {
Ok((candidate_ids.as_deref(), job.as_ref()))
}
_ => Err(OperationError::PermissionDenied(
"operation requires consolidation authority".into(),
)),
}
}
pub(super) fn candidate(&self, id: &str) -> OperationResult<()> {
let (ids, _) = self.consolidation()?;
if ids.is_some_and(|ids| !ids.iter().any(|allowed| allowed == id)) {
return Err(OperationError::PermissionDenied(
"candidate is outside the admitted Job batch".into(),
));
}
Ok(())
}
}
+255
View File
@@ -0,0 +1,255 @@
use super::*;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
/// Executes the same domain operations and bounded projections for every Host.
/// The caller authenticates and validates evidence before issuing `context`.
pub fn execute(
store: &crate::SubjektivStore,
context: &HostOperationContext,
operation: Op,
) -> OperationResult<Response> {
context.require_store(store)?;
let subject_id = context.subject_id();
Ok(match operation {
Op::ResidentSummary(_) => {
context.body()?;
Response::ResidentSummary(resident_summary_output(
store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?,
))
}
Op::ResidentContext(_) => {
context.body()?;
let resident = store
.resident_context(subject_id)
.map_err(subjektiv_store_error)?;
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface),
})
}
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
Op::ListRevisions(input) => {
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
}
Op::ValidateProposal(input) => {
context.body()?;
Response::ProposalValidated(subjektiv_memory_validate_proposal(
store, subject_id, input,
)?)
}
Op::ReceiptStatus(input) => {
context.body()?;
validate_subjektiv_receipt_id(&input.receipt_id)?;
let candidate = store
.staging_candidate(subject_id, &input.receipt_id)
.map_err(subjektiv_store_error)?;
Response::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusResponse {
receipt_id: input.receipt_id,
status: if candidate.is_some() {
server_api::SubjektivMemoryReceiptStatus::Staged
} else {
server_api::SubjektivMemoryReceiptStatus::Missing
},
candidate_id: candidate.map(|record| record.id),
})
}
Op::StageExplicit(input) => {
let attribution = context.body()?.ok_or_else(|| {
OperationError::PermissionDenied(
"explicit staging requires validated Session attribution".into(),
)
})?;
if input.session_id != attribution.session_id {
return Err(OperationError::PermissionDenied(
"explicit staging Session does not match Host attribution".into(),
));
}
Response::Staged(subjektiv_memory_stage_explicit(
store,
input,
attribution.clone(),
)?)
}
Op::ListCandidates(input) => {
let (ids, _) = context.consolidation()?;
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let mut candidates = if let Some(ids) = ids {
let mut pending = Vec::new();
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
if let Some(record) = store
.staging_candidate(subject_id, id)
.map_err(subjektiv_store_error)?
{
pending.push(record);
}
}
}
pending
} else {
store
.pending_staging_candidates(subject_id, limit.saturating_add(1))
.map_err(subjektiv_store_error)?
};
let has_more = candidates.len() > limit;
candidates.truncate(limit);
Response::Candidates(server_api::SubjektivMemoryCandidateListResponse {
items: candidates
.into_iter()
.map(subjektiv_candidate_summary)
.collect(),
has_more,
})
}
Op::ReadCandidate(input) => {
context.candidate(&input.candidate_id)?;
let candidate = store
.staging_candidate(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
OperationError::InvalidInput(format!(
"candidate_not_found: pending candidate `{}` was not found",
input.candidate_id
))
})?;
if store
.staging_resolution(subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.is_some()
{
return Err(OperationError::Conflict(format!(
"candidate_resolved: candidate `{}` is already resolved",
input.candidate_id
)));
}
Response::Candidate(subjektiv_candidate(candidate))
}
Op::DecideCandidate(input) => {
context.candidate(&input.candidate_id)?;
let receipt = store
.decide_candidate(subject_id, subjektiv_candidate_decision(input)?)
.map_err(subjektiv_store_error)?;
Response::CandidateDecided(subjektiv_candidate_decision_response(receipt))
}
Op::PrepareSurface(_) => {
let (ids, job) = context.consolidation()?;
if let Some(ids) = ids {
for id in ids {
if store
.staging_resolution(subject_id, id)
.map_err(subjektiv_store_error)?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"surface generation requires a durable disposition for batch candidate `{id}`"
)));
}
}
}
let generation = store
.prepare_job_surface_generation(
subject_id,
job.map(|b| (b.job_id.as_str(), b.attempt_id.as_str())),
)
.map_err(subjektiv_store_error)?;
let resident = store
.resident_surface(subject_id)
.map_err(subjektiv_store_error)?;
let mut response = subjektiv_surface_prepare_response(generation);
if resident.availability == crate::SurfaceAvailability::Ready {
response.current_snapshot_id = resident
.snapshot
.filter(|s| s.built_from_store_revision == response.store_revision)
.map(|s| s.id);
}
Response::SurfacePrepared(response)
}
Op::PublishSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let points = input
.points
.into_iter()
.map(|point| crate::SurfacePoint {
body_md: point.body_md,
memory_refs: point
.memory_refs
.into_iter()
.map(|r| crate::MemoryRevisionRef {
memory_id: r.memory_id,
revision: r.revision,
})
.collect(),
})
.collect();
let snapshot = store
.publish_surface_generation(subject_id, &input.generation_id, points)
.map_err(subjektiv_store_error)?;
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
snapshot_id: snapshot.id,
built_from_store_revision: snapshot.built_from_store_revision,
empty: snapshot.body_md.is_empty(),
})
}
Op::FailSurface(input) => {
let (_, job) = context.consolidation()?;
if let Some(job) = job {
store
.require_job_surface_generation(
subject_id,
&input.generation_id,
&job.job_id,
&job.attempt_id,
)
.map_err(subjektiv_store_error)?;
}
let revision = store
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
.map_err(subjektiv_store_error)?;
let status = if let Some(job) = job {
store
.validate_job_surface_outcome(
subject_id,
&input.generation_id,
revision,
"failed",
None,
Some(&input.reason_code),
&job.job_id,
&job.attempt_id,
)
.map_err(|e| {
OperationError::Conflict(format!(
"surface failure was not recorded for this attempt: {e}"
))
})?;
"failed_confirmed"
} else {
"failed"
};
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
store_revision: revision,
status: status.into(),
})
}
})
}
+343
View File
@@ -0,0 +1,343 @@
use super::*;
use crate::{MemoryDraft, SubjectRole, SubjectSessionAttribution, SubjektivStore};
use memory::extract::CandidateKind;
use server_api::{
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
};
fn open(root: &std::path::Path, scope: &str) -> (feature_storage::FeatureStorage, SubjektivStore) {
let manager = feature_storage::FeatureStorage::new(root);
let scope = manager.scope(scope).unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
(manager, store)
}
fn query(cursor: Option<String>) -> Op {
Op::Query(server_api::SubjektivMemoryQueryRequest {
query: None,
kinds: None,
states: None,
limit: Some(1),
cursor,
})
}
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
Op::Read(server_api::SubjektivMemoryReadRequest {
memory_id: id.into(),
revision,
offset,
byte_offset,
limit: Some(1),
evidence_cursor: None,
})
}
#[test]
fn neutral_scope_reopen_and_context_attenuation() {
let temp = tempfile::tempdir().unwrap();
let random_scope = uuid::Uuid::now_v7().to_string();
let (manager, store) = open(temp.path(), &random_scope);
assert_eq!(store.scope_id(), random_scope);
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let consolidation =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(
execute(
&store,
&body,
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None })
)
.is_err()
);
assert!(
execute(
&store,
&consolidation,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {})
)
.is_err()
);
let attribution =
SubjectSessionAttribution::new(&subject.id, "local-host", "worker", "session").unwrap();
assert!(
HostOperationContext::validated_body(&store, &subject.id, Some(attribution.clone()))
.is_err()
);
store
.record_session_attribution(attribution.clone())
.unwrap();
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
let (_, foreign) = open(&temp.path().join("foreign"), "foreign-scope");
assert!(matches!(
execute(&foreign, &body, query(None)),
Err(OperationError::PermissionDenied(_))
));
manager.shutdown().unwrap();
drop(store);
let (_manager, reopened) = open(temp.path(), &random_scope);
assert_eq!(reopened.subject(&subject.id).unwrap().unwrap(), subject);
assert!(reopened.session_attribution("session").unwrap().is_some());
}
#[test]
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let original = "日本語\\\"".repeat(10_000);
let first = store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Lesson,
"first",
&original,
"reason",
"initial",
),
)
.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Decision,
"second",
"second body",
"reason",
"initial",
),
)
.unwrap();
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
panic!()
};
assert!(page.has_more);
assert_eq!(page.items.len(), 1);
let cursor = page.next_cursor.unwrap();
store
.create_memory(
&subject.id,
MemoryDraft::active(
CandidateKind::Constraint,
"third",
"third body",
"reason",
"initial",
),
)
.unwrap();
assert!(matches!(
execute(&store, &context, query(Some(cursor))),
Err(OperationError::Conflict(_))
));
assert!(execute(&store, &context, read(&first.id, None, Some(1), None)).is_err());
let mut offset = None;
let mut byte_offset = None;
let mut reconstructed = String::new();
loop {
let Response::Read(page) = execute(
&store,
&context,
read(&first.id, Some(1), offset, byte_offset),
)
.unwrap() else {
panic!()
};
assert!(
serde_json::to_vec_pretty(&page).unwrap().len()
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
);
reconstructed.push_str(&page.body_md);
if !page.body_truncated {
break;
}
assert!(!page.body_md.is_empty());
offset = page.body_next_offset;
byte_offset = page.body_next_byte_offset;
}
assert_eq!(reconstructed, original);
}
#[test]
fn shared_surface_empty_snapshot_is_ready_not_missing() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "local-random-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let consolidation = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![]),
Some(JobAttemptBinding {
job_id: "job".into(),
attempt_id: "attempt".into(),
}),
)
.unwrap();
let prepared: server_api::SubjektivSurfacePrepareRequest = serde_json::from_str("{}").unwrap();
let Response::SurfacePrepared(prepared) =
execute(&store, &consolidation, Op::PrepareSurface(prepared)).unwrap()
else {
panic!()
};
let Response::SurfacePublished(published) = execute(
&store,
&consolidation,
Op::PublishSurface(server_api::SubjektivSurfacePublishRequest {
generation_id: prepared.generation_id,
points: vec![],
}),
)
.unwrap() else {
panic!()
};
assert!(published.empty);
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
let Response::ResidentSummary(resident) = execute(
&store,
&body,
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}),
)
.unwrap() else {
panic!()
};
assert_eq!(
resident.availability,
memory::backend::MemoryResidentSummaryAvailability::Ready
);
assert_eq!(resident.content, None);
}
#[test]
fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
let temp = tempfile::tempdir().unwrap();
let (_manager, store) = open(temp.path(), "random-local-scope");
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let attribution =
SubjectSessionAttribution::new(&subject.id, "host", "worker", "session").unwrap();
store
.record_session_attribution(attribution.clone())
.unwrap();
let context =
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
// Host-resolved public Session evidence with no invented Workspace identity.
let input = server_api::SubjektivMemoryStageExplicitRequest {
receipt_id: "receipt-1".into(),
session_id: "session".into(),
kind: CandidateKind::Lesson,
claim: "Preserve correction history".into(),
why_useful: "Allows auditing".into(),
staleness: None,
proposal: None,
evidence: vec![memory::extract::StagingEvidence {
id: "e1".into(),
entry_range: Some([1, 1]),
kind: memory::schema::EvidenceKind::new("model_output"),
origin: None,
excerpt: Some("Use immutable revisions".into()),
summary: None,
}],
source_refs: vec![memory::schema::SourceEvidenceRef {
session_id: Some("session".into()),
segment_id: Some("segment".into()),
entry_range: Some([1, 1]),
evidence_id: Some("e1".into()),
..Default::default()
}],
};
let mut foreign_session = input.clone();
foreign_session.session_id = "foreign".into();
assert!(matches!(
execute(&store, &context, Op::StageExplicit(foreign_session)),
Err(OperationError::PermissionDenied(_))
));
execute(&store, &context, Op::StageExplicit(input.clone())).unwrap();
execute(&store, &context, Op::StageExplicit(input)).unwrap();
let Response::ReceiptStatus(receipt) = execute(
&store,
&context,
Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest {
receipt_id: "receipt-1".into(),
}),
)
.unwrap() else {
panic!()
};
assert_eq!(
receipt.status,
server_api::SubjektivMemoryReceiptStatus::Staged
);
let candidate_id = receipt.candidate_id.unwrap();
let no_batch =
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
.unwrap();
assert!(matches!(
execute(
&store,
&no_batch,
Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest {
candidate_id: candidate_id.clone()
})
),
Err(OperationError::PermissionDenied(_))
));
let batch = HostOperationContext::validated_consolidation(
&store,
&subject.id,
Some(vec![candidate_id.clone()]),
None,
)
.unwrap();
assert!(
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap())
)
.is_err()
);
let decision = server_api::SubjektivMemoryCandidateDecisionRequest {
request_id: "decision-1".into(),
candidate_id,
reason: "adopt lesson".into(),
decision: server_api::SubjektivMemoryCandidateDecision::Apply {
target: server_api::SubjektivMemoryApplyTarget::Create,
memory: server_api::SubjektivMemoryDraft {
kind: CandidateKind::Lesson,
state: server_api::SubjektivMemoryState::Active,
claim: "Preserve correction history".into(),
body_md: "Use immutable revisions".into(),
why_useful: "Allows auditing".into(),
staleness: None,
derived_from: vec![],
change_reason: "adopt".into(),
},
},
};
let first = execute(&store, &batch, Op::DecideCandidate(decision.clone())).unwrap();
let retry = execute(&store, &batch, Op::DecideCandidate(decision)).unwrap();
assert_eq!(
serde_json::to_value(first).unwrap(),
serde_json::to_value(retry).unwrap()
);
assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1);
execute(
&store,
&batch,
Op::PrepareSurface(serde_json::from_str("{}").unwrap()),
)
.unwrap();
}
+153
View File
@@ -0,0 +1,153 @@
//! Common consolidation Job policy and verified result projections.
//!
//! Hosts authenticate/admit Jobs and bind their immutable batch and attempt.
//! These helpers do not dispatch, replay, or grant Job execution authority.
use serde::Deserialize;
use crate::api::{OperationError, OperationResult};
use crate::{SubjektivStore, SurfaceAvailability};
pub const CANDIDATE_BATCH_LIMIT: usize = 100;
pub const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
pub const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
/// Selects an ordered prefix using the T-704 complete-result budget. Reserve
/// 2,048 bytes for the Subject/Host surface envelope, then twice the JSON-escaped
/// ID size plus 128 bytes per candidate for its compact verified disposition.
pub fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> OperationResult<Vec<String>> {
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| OperationError::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
}
/// Empty ready Subjects need no work, even when forced. Missing/stale/failed
/// surfaces bypass candidate thresholds; confirmed Memory is never discarded.
pub fn consolidation_required(
candidate_count: usize,
total_bytes: u64,
force: bool,
surface_availability: SurfaceAvailability,
) -> bool {
let surface_needs_generation = surface_availability != SurfaceAvailability::Ready;
if candidate_count == 0 {
return surface_needs_generation;
}
force
|| surface_needs_generation
|| candidate_count >= CONSOLIDATION_THRESHOLD_FILES
|| total_bytes >= CONSOLIDATION_THRESHOLD_BYTES
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
/// Verifies the exact Subject/batch, every immutable disposition, and the actual
/// terminal surface outcome begun by this Job attempt. Model claims alone never
/// establish completion. Same admission semantics as the Backend T-704 adapter.
pub fn validate_result(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
job_id: &str,
attempt_id: &str,
value: &serde_json::Value,
) -> OperationResult<()> {
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| OperationError::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != subject_id || result.candidate_ids != batch {
return Err(OperationError::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
for candidate_id in batch {
if store
.staging_resolution(subject_id, candidate_id)
.map_err(|e| OperationError::Store(e.to_string()))?
.is_none()
{
return Err(OperationError::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
job_id,
attempt_id,
)
.map_err(|e| {
OperationError::InvalidInput(format!("consolidation surface result mismatch: {e}"))
})
}
/// Enriches a previously verified result with compact durable dispositions. Full
/// reasons, affected revisions and provenance remain in the receipt store. This
/// deterministic projection neither applies candidates nor creates a new ledger.
pub fn with_dispositions(
store: &SubjektivStore,
subject_id: &str,
batch: &[String],
value: &serde_json::Value,
) -> OperationResult<serde_json::Value> {
let mut dispositions = Vec::new();
for id in batch {
let resolution = store
.staging_resolution(subject_id, id)
.map_err(|e| OperationError::Store(e.to_string()))?
.ok_or_else(|| {
OperationError::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
dispositions.push(serde_json::json!({"candidate_id": id, "action": resolution.action}));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| {
OperationError::InvalidInput("consolidation result must be an object".into())
})?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
}
#[cfg(test)]
mod tests;
+183
View File
@@ -0,0 +1,183 @@
use super::*;
use crate::{
CandidateDecision, CandidateDecisionRequest, StagingResolutionAction, SubjectRole,
SubjectStagingRecord,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
use memory::schema::SourceRef;
#[test]
fn escaped_ids_use_exact_t704_complete_result_budget() {
const MAX_RESULT_BYTES: u32 = 16 * 1024;
let ids: Vec<_> = (0..100)
.map(|i| format!("{i:03}{}", "\\\"".repeat(126)))
.collect();
let batch = bounded_candidate_batch(ids.clone(), MAX_RESULT_BYTES).unwrap();
assert!(!batch.is_empty());
assert!(batch.len() < ids.len());
assert_eq!(batch, ids[..batch.len()]);
let dispositions: Vec<_> = batch
.iter()
.map(|id| serde_json::json!({"candidate_id": id, "action": "already_covered"}))
.collect();
let result = serde_json::json!({
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
});
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
assert!(
bounded_candidate_batch(["one".into()], 2048)
.unwrap()
.is_empty()
);
assert_eq!(
bounded_candidate_batch((0..200).map(|i| i.to_string()), u32::MAX)
.unwrap()
.len(),
CANDIDATE_BATCH_LIMIT
);
}
#[test]
fn trigger_policy_preserves_empty_ready_and_surface_retry_rules() {
for availability in [
SurfaceAvailability::Ungenerated,
SurfaceAvailability::Stale,
SurfaceAvailability::Failed,
] {
assert!(consolidation_required(0, 0, false, availability));
assert!(consolidation_required(1, 1, false, availability));
}
assert!(!consolidation_required(
0,
0,
true,
SurfaceAvailability::Ready
));
assert!(!consolidation_required(
4,
49_999,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
5,
0,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
50_000,
false,
SurfaceAvailability::Ready
));
assert!(consolidation_required(
1,
1,
true,
SurfaceAvailability::Ready
));
}
#[test]
fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
let temp = tempfile::tempdir().unwrap();
let manager = feature_storage::FeatureStorage::new(temp.path());
let scope = manager.scope("local-random-scope").unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
let candidate = SubjectStagingRecord::attach(
&subject.id,
StagingRecord::from_candidate(
"candidate",
"dedup",
SourceRef {
segment_id: "segment".into(),
range: [1, 1],
},
ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: "claim".into(),
why_useful: "reason".into(),
staleness: None,
evidence_ids: vec![],
},
vec![],
vec![],
),
);
store.stage_candidate(candidate).unwrap();
let batch = vec!["candidate".to_string()];
let generation = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let snapshot = store
.publish_surface_generation(&subject.id, &generation.id, vec![])
.unwrap();
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
assert!(
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
.unwrap_err()
.to_string()
.contains("unresolved")
);
assert!(with_dispositions(&store, &subject.id, &batch, &ready).is_err());
store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision".into(),
candidate_id: "candidate".into(),
reason: "invalid".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::Invalid,
affected_memory: vec![],
},
},
)
.unwrap();
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).unwrap();
let enriched = with_dispositions(&store, &subject.id, &batch, &ready).unwrap();
assert_eq!(
enriched["candidate_dispositions"],
serde_json::json!([{"candidate_id": "candidate", "action": "invalid"}])
);
assert_eq!(
enriched,
with_dispositions(&store, &subject.id, &batch, &ready).unwrap()
);
assert!(validate_result(&store, &subject.id, &batch, "job", "other-attempt", &ready).is_err());
assert!(validate_result(&store, &subject.id, &[], "job", "attempt", &ready).is_err());
// A current ready snapshot survives a redundant editor failure. Advance
// Memory first so this attempt genuinely needs a new surface and records failure.
store
.create_memory(
&subject.id,
crate::MemoryDraft::active(
CandidateKind::Lesson,
"new lesson",
"new body",
"reason",
"change",
),
)
.unwrap();
let failure = store
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
.unwrap();
let revision = store
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
.unwrap();
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
let mut false_reason = failed;
false_reason["surface"]["reason_code"] = serde_json::json!("invented");
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &false_reason).is_err());
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -957,6 +957,7 @@ mod tests {
language: "English".into(),
}),
subjektiv_attached: false,
backend_job: None,
})
.unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap();
+9 -8
View File
@@ -457,20 +457,21 @@ pub(crate) async fn run_standalone(
state_dir: PathBuf,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_name = worker_name.unwrap_or_else(|| "local".to_string());
let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| {
manifest::ProfileSelector::parse_cli(&profile)
});
let history_root = workspace_root.clone();
let launch = StandaloneLaunchConfig {
state_dir,
cwd: workspace_root,
profile,
worker_name: worker_name.clone(),
}
.resolve()
.map_err(|error| {
let launch =
StandaloneLaunchConfig::new(workspace_root, state_dir, profile, worker_name.clone());
let launch = if let Some(subject_id) = subject_id {
launch.with_subject(subject_id)
} else {
launch
};
let launch = launch.resolve().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Standalone launch configuration failed: {error}"),
+4 -1
View File
@@ -65,6 +65,7 @@ pub enum LaunchMode {
Spawn {
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
},
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root.
@@ -202,7 +203,8 @@ async fn launch_mode(
LaunchMode::Spawn {
worker_name,
profile,
} => match standalone_spawn::select(&workspace_root, worker_name, profile) {
subject_id,
} => match standalone_spawn::select(&workspace_root, worker_name, profile, subject_id) {
Ok(Some(selection)) => match target.spawn_worker() {
Ok(spawn) => {
console::run_standalone(
@@ -210,6 +212,7 @@ async fn launch_mode(
spawn.state_dir,
Some(selection.worker_name),
Some(selection.profile),
selection.subject_id,
)
.await
}
+134 -10
View File
@@ -12,13 +12,14 @@ use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6;
const VIEWPORT_HEIGHT: u16 = 7;
const FALLBACK_WORKER_NAME: &str = "worker";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StandaloneSpawnSelection {
pub worker_name: String,
pub profile: String,
pub subject_id: Option<String>,
}
#[derive(Debug, Error)]
@@ -55,6 +56,9 @@ enum SpawnAction {
struct SpawnForm {
worker_name: String,
cursor: usize,
subject_id: String,
subject_cursor: usize,
editing_subject: bool,
profile_choices: Vec<ProfileChoice>,
selected_profile: usize,
status: Option<(String, StatusKind)>,
@@ -75,12 +79,26 @@ impl SpawnForm {
Self {
worker_name,
cursor,
subject_id: String::new(),
subject_cursor: 0,
editing_subject: false,
profile_choices,
selected_profile,
status: None,
}
}
fn with_subject(mut self, subject_id: Option<String>) -> Self {
self.subject_id = subject_id.unwrap_or_default();
self.subject_cursor = self.subject_id.chars().count();
self
}
fn subject_selection(&self) -> Option<String> {
let id = self.subject_id.trim();
(!id.is_empty()).then(|| id.to_owned())
}
fn selected_profile(&self) -> &ProfileChoice {
&self.profile_choices[self.selected_profile]
}
@@ -98,6 +116,47 @@ impl SpawnForm {
}
self.status = None;
if key.code == KeyCode::F(2) {
self.editing_subject = !self.editing_subject;
return SpawnAction::None;
}
if self.editing_subject {
match key.code {
KeyCode::Left => self.subject_cursor = self.subject_cursor.saturating_sub(1),
KeyCode::Right => {
self.subject_cursor =
(self.subject_cursor + 1).min(self.subject_id.chars().count())
}
KeyCode::Home => self.subject_cursor = 0,
KeyCode::End => self.subject_cursor = self.subject_id.chars().count(),
KeyCode::Backspace => {
if self.subject_cursor > 0 {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor - 1));
self.subject_cursor -= 1;
}
}
KeyCode::Delete => {
if self.subject_cursor < self.subject_id.chars().count() {
self.subject_id
.remove(byte_index(&self.subject_id, self.subject_cursor));
}
}
KeyCode::Char(ch) if is_safe_worker_char(ch) => {
self.subject_id
.insert(byte_index(&self.subject_id, self.subject_cursor), ch);
self.subject_cursor += 1;
}
KeyCode::Char(_) => {}
// Submit/cancel and profile cycling remain available while editing the Subject.
_ => return self.apply_form_key(key),
}
return SpawnAction::None;
}
self.apply_form_key(key)
}
fn apply_form_key(&mut self, key: KeyEvent) -> SpawnAction {
match key.code {
KeyCode::Esc => SpawnAction::Cancel,
KeyCode::Enter => {
@@ -167,12 +226,14 @@ pub(crate) fn select(
workspace_root: &Path,
worker_name: Option<String>,
profile: Option<String>,
subject_id: Option<String>,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
let default_worker_name = default_worker_name(workspace_root);
if let Some(profile) = profile {
return Ok(Some(StandaloneSpawnSelection {
worker_name: worker_name.unwrap_or(default_worker_name),
profile,
subject_id,
}));
}
@@ -185,7 +246,7 @@ pub(crate) fn select(
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
SpawnForm::new(worker_name, default_worker_name, choices).with_subject(subject_id),
)
})
}
@@ -214,6 +275,7 @@ fn run_picker(
let selection = StandaloneSpawnSelection {
worker_name: form.worker_name.trim().to_owned(),
profile: form.selected_profile().selector.clone(),
subject_id: form.subject_selection(),
};
form.status = Some(("starting worker...".to_owned(), StatusKind::Progress));
terminal.draw(|frame| draw_form(frame, &form))?;
@@ -261,6 +323,7 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(frame.area());
@@ -306,12 +369,32 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("subject: ", Style::default().fg(Color::DarkGray)),
Span::styled(
if form.subject_id.is_empty() {
"(none)"
} else {
&form.subject_id
},
Style::default().fg(Color::Cyan),
),
Span::styled(
" (F2 edit name/Subject ID)",
Style::default().fg(Color::DarkGray),
),
])),
chunks[3],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" enter spawn · left/right edit · esc cancel",
Style::default().fg(Color::DarkGray),
))),
chunks[3],
chunks[4],
);
let (message, color) = form
@@ -331,16 +414,20 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
Span::raw(" "),
Span::styled(message, Style::default().fg(color)),
])),
chunks[4],
chunks[5],
);
let prefix_width = " name: ".chars().count() as u16;
let x = chunks[1]
let (row, prefix, cursor) = if form.editing_subject {
(3, " subject: ", form.subject_cursor)
} else {
(1, " name: ", form.cursor)
};
let x = chunks[row]
.x
.saturating_add(prefix_width)
.saturating_add(form.cursor as u16)
.min(chunks[1].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[1].y));
.saturating_add(prefix.chars().count() as u16)
.saturating_add(cursor as u16)
.min(chunks[row].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[row].y));
}
fn default_worker_name(workspace_root: &Path) -> String {
@@ -394,9 +481,43 @@ mod tests {
fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi");
assert_eq!(form.subject_selection(), None);
assert_eq!(form.selected_profile().selector, "builtin:standalone");
}
#[test]
fn optional_subject_field_edits_independently_and_can_be_cleared() {
let mut form = SpawnForm::new(None, "worker".to_owned(), choices());
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
for ch in "subject-1".chars() {
form.apply_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(form.subject_selection().as_deref(), Some("subject-1"));
assert_eq!(form.worker_name, "worker");
form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder");
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
SpawnAction::Submit
);
for _ in 0..9 {
form.apply_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
assert_eq!(form.subject_selection(), None);
form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(form.worker_name, "worker2");
assert_eq!(form.subject_selection(), None);
}
#[test]
fn explicit_subject_prefills_the_optional_field() {
let form = SpawnForm::new(None, "worker".to_owned(), choices())
.with_subject(Some("subject-id".to_owned()));
assert_eq!(form.subject_selection().as_deref(), Some("subject-id"));
assert_eq!(form.subject_cursor, 10);
}
#[test]
fn tab_and_arrows_cycle_profiles() {
let mut form = SpawnForm::new(None, "yoi".to_owned(), choices());
@@ -453,6 +574,7 @@ mod tests {
assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi"));
assert!(rendered.contains("subject: (none)"));
assert!(rendered.contains("profile: builtin:standalone (default) — Standalone"));
assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
}
@@ -504,10 +626,12 @@ mod tests {
Path::new("/home/hare/Project/yoi"),
None,
Some("builtin:coder".to_owned()),
Some("subject-id".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(selection.worker_name, "yoi");
assert_eq!(selection.profile, "builtin:coder");
assert_eq!(selection.subject_id.as_deref(), Some("subject-id"));
}
}
+48
View File
@@ -52,6 +52,7 @@ pub struct WorkerBootstrap<St> {
transport: WorkerControllerTransport,
model_client: Option<Box<dyn LlmClient>>,
workdir_session: Option<WorkdirSessionHandle>,
subjektiv_host: Option<std::sync::Arc<dyn crate::subjektiv::SubjektivHost>>,
}
/// A constructed Worker whose host-owned live bindings can still be installed
@@ -106,6 +107,7 @@ where
transport,
model_client: None,
workdir_session: None,
subjektiv_host: None,
}
}
@@ -127,6 +129,16 @@ where
self
}
/// Bind an explicit, process-owned subject capability. Profile policy alone
/// is inert; the connection is never persisted into the Worker manifest.
pub fn with_subjektiv_host(
mut self,
host: std::sync::Arc<dyn crate::subjektiv::SubjektivHost>,
) -> Self {
self.subjektiv_host = Some(host);
self
}
/// Construct the Worker without exposing a controller handle. Runtime hosts
/// use this seam to bind Workdir, observation, Flow, and other live services
/// before [`PreparedWorker::start`] performs Feature installation.
@@ -147,6 +159,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::NewSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
@@ -170,6 +200,24 @@ where
if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session));
}
let explicit_subjektiv_host = self.subjektiv_host.is_some();
worker.bind_subjektiv_host(self.subjektiv_host);
if explicit_subjektiv_host
&& worker.manifest().feature.subjektiv.profile.enabled
&& !worker
.manifest()
.feature
.subjektiv
.profile
.consolidation_tools
{
worker
.finalize_subjektiv_session_attribution(
true,
crate::SubjektivSessionAttributionLifecycle::RestoredSession,
)
.map_err(WorkerBootstrapError::Worker)?;
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
+18 -17
View File
@@ -1377,6 +1377,7 @@ where
None => durable_parent_notifications,
};
let backend_job = worker.backend_job().cloned();
let subjektiv_host = worker.subjektiv_host_connection()?;
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone();
@@ -1443,8 +1444,8 @@ where
workspace_client.clone(),
);
if binding.subjektiv_consolidation {
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_host(
subjektiv_host.clone().ok_or_else(|| std::io::Error::other("Backend Job requires subjektiv Host capability"))?, spawner_manifest.clone(), worker.llm_client_handle(),
prompts.clone(), spawner_workspace_context.clone(),
)?;
result_feature = result_feature.with_surface(surface);
@@ -1453,7 +1454,9 @@ where
}
let memory_profile = &worker.manifest().feature.memory.profile;
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
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(),
+116 -174
View File
@@ -4,8 +4,10 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -158,6 +160,7 @@ impl dyn WorkspaceClient + '_ {
}
}
#[cfg(test)]
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
@@ -327,9 +330,7 @@ pub fn workspace_http_memory_tools(client: Arc<dyn WorkspaceClient>) -> Vec<Tool
]
}
pub fn workspace_http_subjektiv_consolidation_tools(
client: Arc<dyn WorkspaceClient>,
) -> Vec<ToolDefinition> {
fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec<ToolDefinition> {
use SubjectConsolidationOperation as Operation;
[
(
@@ -371,14 +372,14 @@ pub fn workspace_http_subjektiv_consolidation_tools(
]
.into_iter()
.map(|(name, description, schema, operation)| {
let client = Arc::clone(&client);
let host = host.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(schema.clone()),
Arc::new(SubjectConsolidationTool {
client: Arc::clone(&client),
host: host.clone(),
operation,
}) as Arc<dyn Tool>,
)
@@ -398,7 +399,7 @@ enum SubjectConsolidationOperation {
}
struct SubjectConsolidationTool {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
operation: SubjectConsolidationOperation,
}
@@ -434,59 +435,17 @@ impl Tool for SubjectConsolidationTool {
)?)
}
};
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed(
"subjektiv consolidation requires Workspace authority".into(),
)
})?;
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let parsed =
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| (diagnostic.code.clone(), error.message.clone()))
}) {
let response = match self.host.memory(operation) {
Ok(response) => response,
Err(SubjektivHostError::Conflict { code, message }) => {
return Ok(ToolOutput {
summary: format!("Candidate decision requires reread: {code}."),
content: Some(
serde_json::json!({
"status": "error",
"error": { "code": code, "message": message }
})
.to_string(),
),
content: Some(serde_json::json!({"status":"error", "error":{"code":code,"message":message}}).to_string()),
attachments: Vec::new(),
});
}
let detail = parsed.map(|error| error.message).unwrap_or(response.body);
return Err(if matches!(response.status, 400 | 404 | 409 | 422) {
ToolError::InvalidArgument(detail)
} else {
ToolError::ExecutionFailed(detail)
});
}
let response: server_api::SubjektivMemoryBackendResponse =
serde_json::from_str(&response.body)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
Err(error) => return Err(error.into()),
};
let summary = match &response {
server_api::SubjektivMemoryBackendResponse::Query(value) => {
format!("Found {} subject Memory item(s).", value.items.len())
@@ -686,7 +645,7 @@ struct SubjectBehaviorRefreshState {
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
host: SubjektivHostConnection,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
@@ -730,16 +689,12 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
match self.host.memory(
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
other => Err(SubjektivHostError::Unavailable(format!(
"unexpected subject resident context response: {other:?}"
))),
}
@@ -862,10 +817,11 @@ const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-befor
pub(crate) struct SubjektivResidentRestoreRefreshFeature {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
impl SubjektivResidentRestoreRefreshFeature {
#[cfg(test)]
pub(crate) fn for_host(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
@@ -875,6 +831,20 @@ impl SubjektivResidentRestoreRefreshFeature {
lifecycle_enabled.then(|| Self::new(source, prompts, workspace_id))
}
pub(crate) fn for_connection(
lifecycle_enabled: bool,
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: Option<String>,
) -> Option<Self> {
lifecycle_enabled.then_some(Self {
source,
prompts,
workspace_id,
})
}
#[cfg(test)]
pub(crate) fn new(
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -883,7 +853,7 @@ impl SubjektivResidentRestoreRefreshFeature {
Self {
source,
prompts,
workspace_id: workspace_id.into(),
workspace_id: Some(workspace_id.into()),
}
}
}
@@ -981,7 +951,7 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_id: String,
workspace_id: Option<String>,
}
#[async_trait]
@@ -1000,7 +970,7 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
context.system_items().append_resident_summary_refresh(
body,
session_store::PromptRenderProvenance {
workspace_id: Some(self.workspace_id.clone()),
workspace_id: self.workspace_id.clone(),
config_revision: projection.config_revision,
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
@@ -1114,51 +1084,25 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
}
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
manifest.feature.subjektiv.execution_enabled()
manifest.feature.subjektiv.profile.enabled
&& !manifest.feature.subjektiv.profile.consolidation_tools
}
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
if !ordinary_subjektiv_features_enabled(manifest) || host.is_none() {
return Ok(None);
}
manifest
.feature
.subjektiv
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !client.is_available() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv resident Memory requires Backend Workspace API authority",
)
})?;
let settings = manifest
.feature
.subjektiv
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv Workspace settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
));
}
.map_err(std::io::Error::other)?;
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
host: host.expect("checked explicit connection"),
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
@@ -1172,57 +1116,51 @@ pub(crate) struct SubjektivConsolidationFeatureInstallPlan {
impl SubjektivConsolidationFeatureInstallPlan {
pub(crate) fn prepare(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
host: Option<SubjektivHostConnection>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
) -> std::io::Result<Option<Self>> {
if !manifest.feature.subjektiv.profile.consolidation_tools {
let config = &manifest.feature.subjektiv;
if !config.profile.enabled || !config.profile.consolidation_tools || host.is_none() {
return Ok(None);
}
let config = &manifest.feature.subjektiv;
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !config.execution_enabled() || config.profile.extraction.enabled {
config.validate_execution().map_err(std::io::Error::other)?;
let host = host.expect("checked explicit connection");
if config.profile.extraction.enabled || !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation profile requires trusted subject settings without automatic extraction",
));
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv consolidation tools require Backend Workspace API authority",
)
})?;
let settings = config
.workspace_settings()
.expect("validated enabled subjektiv config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"subjektiv settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
"subjektiv consolidation requires explicit Host grant without automatic extraction",
));
}
let system_prompt_override = prompts
.subjektiv_memory_consolidation_system(&settings.language)
.subjektiv_memory_consolidation_system(&host.host.settings().language)
.map_err(|error| std::io::Error::other(error.to_string()))?;
Ok(Some(Self {
module: SubjektivConsolidationToolsFeature {
tools: workspace_http_subjektiv_consolidation_tools(client),
},
module: SubjektivConsolidationToolsFeature::new(host)?,
system_prompt_override,
}))
}
}
#[derive(Clone)]
pub(crate) struct SubjektivConsolidationToolsFeature {
pub struct SubjektivConsolidationToolsFeature {
tools: Vec<ToolDefinition>,
}
impl SubjektivConsolidationToolsFeature {
/// Reusable installation for a generic Job with an explicit attenuated Host.
pub fn new(host: SubjektivHostConnection) -> std::io::Result<Self> {
if !host.host.consolidation_granted() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Host did not grant consolidation",
));
}
Ok(Self {
tools: subjektiv_consolidation_tools(host),
})
}
}
impl FeatureModule for SubjektivConsolidationToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(
@@ -1293,6 +1231,29 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn backend_resident_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
let host = crate::subjektiv::BackendSubjektivHost::from_resolved_config(
&manifest.feature.subjektiv,
client,
)?;
let host = host
.map(|host| {
SubjektivHostConnection::new(
host,
crate::subjektiv::SubjektivHostContext {
worker_id: manifest.worker.name.clone(),
session_id: "session-1".into(),
},
)
})
.transpose()?;
ordinary_subjektiv_resident_summary_source(manifest, host, prompts)
}
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
@@ -1601,7 +1562,7 @@ mod tests {
let plan = SubjektivConsolidationFeatureInstallPlan::prepare(
&manifest,
test_client(),
Some(crate::subjektiv::test_connection(test_client())),
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)
.unwrap()
@@ -1696,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());
+58 -7
View File
@@ -8,7 +8,7 @@
//! cannot be preempted by dropping its tool future and must itself be bounded.
mod result;
pub use result::{JobResultFeature, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
use std::borrow::Cow;
use std::path::Path;
@@ -72,6 +72,28 @@ pub struct PreparedInternalJob {
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
system_prompt: String,
features: FeatureRegistryBuilder,
finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
/// Explicit Host-supplied domain modules and the Profile requirements they satisfy.
/// Profile names never issue this grant. The Host must bind all module operations to
/// the immutable request/attempt and fence them on cancellation, timeout and shutdown.
#[derive(Default)]
pub struct JobFeatureGrant {
pub features: FeatureRegistryBuilder,
pub satisfied_requirements: Vec<&'static str>,
pub finalizer: Option<Arc<dyn JobResultFinalizer>>,
}
pub fn prepare_job_with_grant(
request: &::job::JobRequest,
cwd: &Path,
client: Option<Box<dyn LlmClient>>,
grant: JobFeatureGrant,
) -> Result<PreparedInternalJob, JobExecutionError> {
let registry = ProfileDiscovery::user_settings().discover()?;
prepare_job_from_registry_with_grant(request, cwd, client, &registry, grant)
}
/// Resolve exactly the requested registry Profile using the existing user settings registry.
@@ -91,6 +113,16 @@ fn prepare_job_from_registry(
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()
@@ -101,7 +133,16 @@ fn prepare_job_from_registry(
ProfileResolveOptions::with_worker_name("internal-job"),
)?;
let mut manifest = resolved.manifest;
validate_result_only_profile(&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(
@@ -113,7 +154,7 @@ fn prepare_job_from_registry(
cwd: Cow::Borrowed("(no filesystem)"),
language: &manifest.engine.language,
scope: &scope,
tool_names: vec![SUBMIT_JOB_RESULT_TOOL.into()],
tool_names,
feature_instructions: &[],
agents_md: None,
resident_summary: None,
@@ -148,6 +189,8 @@ fn prepare_job_from_registry(
manifest,
client,
system_prompt,
features: grant.features,
finalizer: grant.finalizer,
})
}
@@ -165,7 +208,11 @@ impl PreparedInternalJob {
where
F: FnOnce(tokio::sync::mpsc::Sender<()>),
{
let feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?;
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 {
@@ -182,7 +229,7 @@ impl PreparedInternalJob {
system_prompt: self.system_prompt,
input: self.request.worker_input(&attempt_id)?,
cache_key: None,
features: FeatureRegistryBuilder::new().with_module(feature.clone()),
features: self.features.with_module(feature.clone()),
required_tools: &[SUBMIT_JOB_RESULT_TOOL],
authority: InternalWorkerAuthority {
workspace: WorkerWorkspaceContext::no_workspace(),
@@ -218,7 +265,11 @@ impl PreparedInternalJob {
}
/// Validate requirements, not Profile names. Nothing configured as active is silently stripped.
fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExecutionError> {
fn validate_profile_with_grant(
manifest: &WorkerManifest,
satisfied: &[&str],
) -> Result<(), JobExecutionError> {
let manifest::FeatureConfig {
task,
memory,
@@ -306,7 +357,7 @@ fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExec
manifest.session.record_event_trace,
),
] {
if enabled {
if enabled && !satisfied.contains(&name) {
unavailable.push(name);
}
}
+85 -1
View File
@@ -23,6 +23,29 @@ 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 {
@@ -32,6 +55,7 @@ struct SubmitJobResultInput {
#[derive(Default)]
struct SubmissionState {
sealed: Option<Value>,
prepared: Option<Value>,
unconfirmed: Option<String>,
}
@@ -46,6 +70,9 @@ pub struct JobResultFeature {
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 {
@@ -65,9 +92,26 @@ impl JobResultFeature {
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()
}
@@ -107,6 +151,44 @@ impl Tool for JobResultFeature {
.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
@@ -129,12 +211,13 @@ impl Tool for JobResultFeature {
} 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: input.result,
result: prepared,
};
let mut accepted = self.accepted.lock().expect("Job result state poisoned");
if accepted.is_none() {
@@ -156,6 +239,7 @@ impl Tool for JobResultFeature {
}
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancellation.cancel();
self.cancelled
.lock()
.expect("Job cancellation state poisoned")
+459
View File
@@ -1,14 +1,21 @@
//! Scripted in-process model executions, not provider/process tests.
use super::*;
use crate::feature::background::BackgroundTaskCancellation;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request};
use agen::tool::{Tool, ToolExecutionContext};
use agen::tool::{ToolError, ToolOutput};
use async_trait::async_trait;
use futures::Stream;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[derive(Clone)]
struct ScriptedClient {
@@ -592,6 +599,458 @@ fn unknown_prompt_and_model_are_not_substituted_even_with_injected_client() {
}
}
// A domain capability is issued by the Host, never by the selected Profile.
#[derive(Clone, Default)]
struct GrantedTool(Arc<AtomicUsize>);
#[async_trait]
impl Tool for GrantedTool {
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutput {
summary: "granted".into(),
content: None,
attachments: vec![],
})
}
}
impl FeatureModule for GrantedTool {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("test-job-domain", "Test domain").with_tool(
ToolDeclaration::new("GrantedDomainTool", "Explicit Host capability"),
)
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let tool = self.clone();
context.tools().register(ToolContribution::new(
"GrantedDomainTool",
Arc::new(move || {
(
agen::tool::ToolMeta::new("GrantedDomainTool"),
Arc::new(tool.clone()) as Arc<dyn Tool>,
)
}),
))
}
}
#[tokio::test]
async fn domain_grant_is_explicit_not_profile_derived_and_preserves_selected_policy() {
let root = tempfile::tempdir().unwrap();
let mut profile = artifact("internal.flow_verifier_system", 100000, 1000);
profile["feature"] = json!({"task":{"enabled":true}});
let registry = registry(root.path(), profile.clone(), profile);
let marker = GrantedTool::default();
for selector in ["user:a", "user:b", "default"] {
assert!(matches!(
prepare_job_from_registry(
&request(selector),
root.path(),
Some(Box::new(ScriptedClient::new(vec![]))),
&registry,
),
Err(JobExecutionError::UnsupportedProfile(_))
));
let client = ScriptedClient::new(vec![
call("GrantedDomainTool", json!({})),
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})),
prose(),
]);
let job = prepare_job_from_registry_with_grant(
&request(selector),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
features: FeatureRegistryBuilder::new().with_module(marker.clone()),
satisfied_requirements: vec!["feature.task"],
finalizer: None,
},
)
.unwrap();
assert_eq!(
job.manifest.model.model_id.as_deref(),
Some("scripted-model")
);
assert_eq!(
job.manifest.engine.instruction,
"internal.flow_verifier_system"
);
assert_eq!(job.manifest.engine.max_turns.unwrap().get(), MAX_JOB_TURNS);
assert_eq!(job.manifest.engine.max_tokens, Some(MAX_JOB_TOKENS));
assert!(job.manifest.scope.allow.is_empty());
assert!(job.manifest.delegation_scope.allow.is_empty());
job.run("attempt".into(), Arc::new(RecordingSink::default()), |_| {})
.await
.unwrap();
for sent in client.requests.lock().unwrap().iter() {
let mut names = sent
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
names.sort();
assert_eq!(names, ["GrantedDomainTool", SUBMIT_JOB_RESULT_TOOL]);
assert_eq!(sent.config.max_tokens, Some(MAX_JOB_TOKENS));
}
}
assert_eq!(marker.0.load(Ordering::SeqCst), 3);
}
#[test]
fn domain_grant_does_not_satisfy_unrelated_profile_requirements() {
let root = tempfile::tempdir().unwrap();
for (key, value, needle) in [
(
"feature",
json!({"task":{"enabled":true},"web":{"enabled":true}}),
"feature.web",
),
("scope", json!("workspace_read"), "scope"),
(
"delegation_scope",
json!("workspace_write"),
"delegation_scope",
),
] {
let mut profile = artifact("default", 512, 3);
profile[key] = value;
let registry = registry(root.path(), profile.clone(), profile);
let error = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(ScriptedClient::new(vec![]))),
&registry,
JobFeatureGrant {
features: FeatureRegistryBuilder::new().with_module(GrantedTool::default()),
satisfied_requirements: vec!["feature.task"],
finalizer: None,
},
)
.err()
.unwrap();
assert!(
matches!(error, JobExecutionError::UnsupportedProfile(_)),
"{error}"
);
assert!(error.to_string().contains(needle), "{error}");
}
}
#[derive(Default)]
struct ControlledFinalizer {
calls: AtomicUsize,
active: Arc<AtomicUsize>,
manifests: Mutex<Vec<manifest::WorkerManifest>>,
entered: tokio::sync::Notify,
release: tokio::sync::Notify,
cancellation_seen: tokio::sync::Notify,
cleanup: tokio::sync::Notify,
cancelled: AtomicBool,
gated: bool,
// Reusing a captured request with the supplied client proves transport identity.
probe: Option<Arc<Mutex<Vec<Request>>>>,
reject_profile: bool,
}
struct FinalizerResource(Arc<AtomicUsize>);
impl Drop for FinalizerResource {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl JobResultFinalizer for ControlledFinalizer {
fn validate_profile(&self, _: &manifest::WorkerManifest) -> Result<(), String> {
if self.reject_profile {
Err("test finalizer refuses selected policy".into())
} else {
Ok(())
}
}
async fn finalize(
&self,
result: Value,
manifest: manifest::WorkerManifest,
client: Box<dyn LlmClient>,
cancellation: BackgroundTaskCancellation,
) -> Result<Value, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.active.fetch_add(1, Ordering::SeqCst);
let _resource = FinalizerResource(self.active.clone());
self.manifests.lock().unwrap().push(manifest);
if let Some(requests) = &self.probe {
let request = requests.lock().unwrap()[0].clone();
let events = client
.stream(request)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let _: Vec<_> = futures::StreamExt::collect(events).await;
}
self.entered.notify_one();
if self.gated {
tokio::select! {
_ = self.release.notified() => {},
_ = cancellation.cancelled() => {
self.cancelled.store(true, Ordering::SeqCst);
self.cancellation_seen.notify_one();
self.cleanup.notified().await;
return Err(ToolError::Cancelled(ToolOutput {
summary: "finalizer cleaned up".into(), content: None, attachments: vec![],
}));
}
}
}
Ok(json!({"prepared":result,"completion":self.calls.load(Ordering::SeqCst)}))
}
}
async fn signalled(notify: &tokio::sync::Notify) {
tokio::time::timeout(std::time::Duration::from_secs(2), notify.notified())
.await
.unwrap();
}
#[tokio::test]
async fn result_finalization_is_awaited_with_selected_model_policy_and_no_orphan_resource() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 777, 4),
artifact("default", 512, 3),
);
let client = ScriptedClient::new(vec![
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"draft":true}})),
prose(),
prose(),
]);
let finalizer = Arc::new(ControlledFinalizer {
gated: true,
probe: Some(client.requests.clone()),
..Default::default()
});
let job = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.unwrap();
let expected = serde_json::to_value(&job.manifest).unwrap();
let sink = Arc::new(RecordingSink::default());
let recording = sink.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
let run = tokio::spawn(async move {
job.run("attempt".into(), recording, move |sender| {
let _ = tx.send(sender);
})
.await
});
let sender = rx.await.unwrap();
signalled(&finalizer.entered).await;
assert!(!run.is_finished());
assert!(
sink.0.lock().unwrap().is_empty(),
"sink cannot accept an unfinished result"
);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 1);
assert_eq!(
serde_json::to_value(&finalizer.manifests.lock().unwrap()[0]).unwrap(),
expected
);
assert_eq!(
client.requests.lock().unwrap().len(),
2,
"finalizer uses the injected selected transport"
);
finalizer.release.notify_one();
let accepted = tokio::time::timeout(std::time::Duration::from_secs(2), run)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(
accepted.submission.result,
json!({"prepared":{"draft":true},"completion":1})
);
assert_eq!(sink.0.lock().unwrap().as_slice(), &[accepted.submission]);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert!(sender.is_closed());
}
#[tokio::test]
async fn cancellation_reaches_finalizer_and_runner_waits_for_cleanup_without_acceptance() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 512, 3),
artifact("default", 512, 3),
);
let finalizer = Arc::new(ControlledFinalizer {
gated: true,
..Default::default()
});
let job = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(ScriptedClient::new(vec![
call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})),
prose(),
]))),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.unwrap();
let sink = Arc::new(RecordingSink::default());
let recording = sink.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
let run = tokio::spawn(async move {
job.run("attempt".into(), recording, move |sender| {
let _ = tx.send(sender);
})
.await
});
let sender = rx.await.unwrap();
signalled(&finalizer.entered).await;
assert!(sink.0.lock().unwrap().is_empty());
sender.send(()).await.unwrap();
signalled(&finalizer.cancellation_seen).await;
assert!(finalizer.cancelled.load(Ordering::SeqCst));
assert!(
!run.is_finished(),
"cancellation must await consumer resource cleanup"
);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 1);
assert!(sink.0.lock().unwrap().is_empty());
finalizer.cleanup.notify_one();
let outcome = tokio::time::timeout(std::time::Duration::from_secs(2), run)
.await
.unwrap()
.unwrap();
assert!(
matches!(outcome, Err(JobExecutionError::Cancelled)),
"{outcome:?}"
);
assert!(sink.0.lock().unwrap().is_empty());
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
assert!(sender.is_closed());
}
#[test]
fn finalizer_can_refuse_selected_profile_before_execution() {
let root = tempfile::tempdir().unwrap();
let registry = registry(
root.path(),
artifact("default", 512, 3),
artifact("default", 512, 3),
);
let finalizer = Arc::new(ControlledFinalizer {
reject_profile: true,
..Default::default()
});
let client = ScriptedClient::new(vec![]);
let error = prepare_job_from_registry_with_grant(
&request("user:a"),
root.path(),
Some(Box::new(client.clone())),
&registry,
JobFeatureGrant {
finalizer: Some(finalizer.clone()),
..Default::default()
},
)
.err()
.unwrap();
assert!(matches!(error, JobExecutionError::UnsupportedProfile(_)));
assert!(
error
.to_string()
.contains("test finalizer refuses selected policy")
);
assert!(client.requests.lock().unwrap().is_empty());
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 0);
}
#[derive(Default)]
struct UncertainThenAcceptedSink(Mutex<Vec<::job::JobResultSubmission>>);
impl JobResultSink for UncertainThenAcceptedSink {
fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String> {
let mut submissions = self.0.lock().unwrap();
submissions.push(submission);
if submissions.len() == 1 {
Err("durable acceptance is uncertain".into())
} else {
Ok(())
}
}
}
#[tokio::test]
async fn exact_uncertain_sink_retry_reuses_prepared_outcome_without_rerunning_finalizer() {
let root = tempfile::tempdir().unwrap();
let job = prepared(root.path(), &request("user:a"), ScriptedClient::new(vec![]));
let finalizer = Arc::new(ControlledFinalizer::default());
let sink = Arc::new(UncertainThenAcceptedSink::default());
let feature = JobResultFeature::new(&request("user:a"), "attempt".into(), sink.clone())
.unwrap()
.with_finalizer(finalizer.clone(), job.manifest, job.client);
assert!(
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert!(feature.unconfirmed().is_some());
assert!(feature.accepted().lock().unwrap().is_none());
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert!(
feature
.execute(
r#"{"result":{"draft":false}}"#,
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert_eq!(sink.0.lock().unwrap().len(), 1);
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
feature
.execute(
r#"{"result":{"draft":true}}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(feature.unconfirmed().is_none());
let submissions = sink.0.lock().unwrap();
assert_eq!(submissions.len(), 2);
assert_eq!(submissions[0], submissions[1]);
assert_eq!(
submissions[0].result,
json!({"prepared":{"draft":true},"completion":1})
);
assert_eq!(
feature.accepted().lock().unwrap().as_ref(),
Some(&submissions[1])
);
assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1);
assert_eq!(finalizer.active.load(Ordering::SeqCst), 0);
}
struct UnknownSink(Mutex<Vec<::job::JobResultSubmission>>);
impl JobResultSink for UnknownSink {
fn submit(&self, value: ::job::JobResultSubmission) -> Result<(), String> {
+1
View File
@@ -25,6 +25,7 @@ pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
pub mod spawn;
pub mod subjektiv;
pub mod wip;
mod internal_worker;
+3 -1
View File
@@ -712,7 +712,9 @@ impl SessionCapture {
fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance {
match origin {
WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput,
WorkerHistoryProvenance::HumanInput { .. } | WorkerHistoryProvenance::LocalHumanInput => {
SessionEntryProvenance::HumanInput
}
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
WorkerHistoryProvenance::BackendInstruction { .. } => {
+700
View File
@@ -0,0 +1,700 @@
//! Explicit Host capability for subject-scoped Memory and committed Sessions.
//!
//! A manifest is policy, not a connection. Hosts bind Subject/storage authority
//! in their implementation; the Worker supplies its own Worker/Session context.
//! Neither Subject identity nor filesystem/Workspace authority is model input.
pub use crate::feature::builtin::memory::SubjektivConsolidationToolsFeature;
pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
use std::sync::Arc;
use std::time::Duration;
use agen::tool::ToolError;
use memory::backend::{
MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryStageCandidateOperation,
};
use server_api::*;
use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation};
/// Host-issued execution context, never deserialized from model arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostContext {
pub worker_id: String,
pub session_id: String,
}
/// Explicit local configuration. This is not a trusted Workspace snapshot and
/// is not persisted in the reusable Worker manifest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostSettings {
pub language: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SubjektivHostError {
#[error("{message}")]
Conflict { code: String, message: String },
#[error("{0}")]
InvalidArgument(String),
#[error("{0}")]
Unavailable(String),
}
impl From<SubjektivHostError> for ToolError {
fn from(error: SubjektivHostError) -> Self {
match error {
SubjektivHostError::Conflict { code, message } => {
Self::StructuredConflict { code, message }
}
SubjektivHostError::InvalidArgument(message) => Self::InvalidArgument(message),
SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message),
}
}
}
/// Object-safe capability implemented by Backend and standalone Hosts.
/// Implementations must enforce their bound Subject, Worker/Session authority,
/// committed evidence and (for Jobs) immutable batch attenuation. Knowing an
/// identifier is not a grant. No method grants ambient filesystem access.
pub trait SubjektivHost: Send + Sync + 'static {
fn settings(&self) -> SubjektivHostSettings;
/// Separate explicit grant for decision/surface execution; Profile policy
/// alone must never authorize consolidation tools.
fn consolidation_granted(&self) -> bool {
false
}
/// Optional real Backend identity, used only for prompt provenance.
fn workspace_id(&self) -> Option<String> {
None
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError>;
fn session(
&self,
context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError>;
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError>;
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError>;
fn request_consolidation(
&self,
context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError>;
}
/// Backend adapter. Existing authenticated Workspace operations and snapshot
/// validation remain the authority; no local Workspace is synthesized.
pub struct BackendSubjektivHost {
client: Arc<dyn WorkspaceClient>,
settings: SubjektivHostSettings,
workspace_id: String,
consolidation_granted: bool,
}
impl BackendSubjektivHost {
pub fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Arc<dyn SubjektivHost>>> {
config.validate_execution().map_err(std::io::Error::other)?;
if !config.execution_enabled() {
return Ok(None);
}
let settings = config
.workspace_settings()
.expect("execution requires trusted settings");
if !client.is_available() || client.workspace_id() != Some(settings.workspace_id.as_str()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"subjektiv requires available Backend authority matching trusted Workspace settings",
));
}
Ok(Some(Arc::new(Self {
client,
workspace_id: settings.workspace_id,
settings: SubjektivHostSettings {
language: settings.language,
},
consolidation_granted: config.profile.consolidation_tools,
})))
}
fn execute<T: serde::de::DeserializeOwned>(
&self,
operation: WorkspaceServerOperation,
) -> Result<T, SubjektivHostError> {
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
decode(response)
}
}
fn decode<T: serde::de::DeserializeOwned>(
response: crate::WorkspaceResponse,
) -> Result<T, SubjektivHostError> {
if !response.is_success() {
let parsed = serde_json::from_str::<RepositoryApiError>(&response.body).ok();
let message = parsed
.as_ref()
.map(|error| error.message.clone())
.unwrap_or(response.body);
if let Some(code) = parsed.as_ref().and_then(|error| {
error
.diagnostics
.iter()
.find(|diagnostic| {
matches!(
diagnostic.code.as_str(),
"revision_conflict"
| "stale_cursor"
| "candidate_decision_conflict"
| "subject_scope_mismatch"
)
})
.map(|diagnostic| diagnostic.code.clone())
}) {
return Err(SubjektivHostError::Conflict { code, message });
}
return Err(if response.status == 409 {
SubjektivHostError::Conflict {
code: "conflict".into(),
message,
}
} else if matches!(response.status, 400 | 404 | 422) {
SubjektivHostError::InvalidArgument(message)
} else {
SubjektivHostError::Unavailable(message)
});
}
serde_json::from_str(&response.body).map_err(|error| {
SubjektivHostError::Unavailable(format!("decode subjektiv response: {error}"))
})
}
impl SubjektivHost for BackendSubjektivHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: self.settings.language.clone(),
}
}
fn workspace_id(&self) -> Option<String> {
Some(self.workspace_id.clone())
}
fn consolidation_granted(&self) -> bool {
self.consolidation_granted
}
fn memory(
&self,
_context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
// Preserve the bounded resident-context request timeout.
if matches!(
operation,
SubjektivMemoryBackendOperation::ResidentContext(_)
) {
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/memory", self.workspace_id),
serde_json::to_string(&SubjektivMemoryBackendRequest { operation })
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
return decode(
self.client
.execute_with_timeout(request, Duration::from_secs(5))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
);
}
self.execute(WorkspaceServerOperation::SubjektivMemory(
SubjektivMemoryBackendRequest { operation },
))
}
fn session(
&self,
_context: &SubjektivHostContext,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
// Session domain diagnostics are typed even on non-2xx responses.
if let Ok(value) = serde_json::from_str(&response.body) {
return Ok(value);
}
decode(response)
}
fn stage_candidate(
&self,
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivStageCandidate(
SubjektivStageCandidateRequest {
session_id: context.session_id.clone(),
operation,
},
))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.execute(WorkspaceServerOperation::SubjektivRecordSession(
SubjektivRecordSessionRequest {
session_id: context.session_id.clone(),
create_if_missing,
},
))
}
fn request_consolidation(
&self,
_context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
let response = self
.client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/subjektiv/consolidation", self.workspace_id),
serde_json::to_string(&MemoryConsolidateStagingRequest {
force: operation.force,
})
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?,
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
let response: MemoryConsolidationResponse = decode(response)?;
Ok(MemoryConsolidationOutput {
status: response.status,
summary: response.summary,
candidate_count: response.candidate_count,
total_bytes: response.total_bytes,
})
}
}
/// Live connection with Worker-supplied authority. Not serializable and never
/// inherited by an ordinary SubWorker.
#[derive(Clone)]
pub struct SubjektivHostConnection {
pub host: Arc<dyn SubjektivHost>,
pub context: SubjektivHostContext,
}
impl SubjektivHostConnection {
pub fn new(
host: Arc<dyn SubjektivHost>,
context: SubjektivHostContext,
) -> std::io::Result<Self> {
let language = host.settings().language;
if context.worker_id.trim().is_empty()
|| context.session_id.trim().is_empty()
|| context.worker_id.chars().any(char::is_control)
|| context.session_id.chars().any(char::is_control)
|| !manifest::is_normalized_workspace_memory_language(&language)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid subjektiv Host context/settings",
));
}
Ok(Self { host, context })
}
pub fn memory(
&self,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
if let SubjektivMemoryBackendOperation::StageExplicit(input) = &operation
&& input.session_id != self.context.session_id
{
return Err(SubjektivHostError::InvalidArgument(
"explicit staging targets a foreign Session".into(),
));
}
if matches!(
operation,
SubjektivMemoryBackendOperation::DecideCandidate(_)
| SubjektivMemoryBackendOperation::PrepareSurface(_)
| SubjektivMemoryBackendOperation::PublishSurface(_)
| SubjektivMemoryBackendOperation::FailSurface(_)
) && !self.host.consolidation_granted()
{
return Err(SubjektivHostError::InvalidArgument(
"Host did not grant consolidation".into(),
));
}
self.host.memory(&self.context, operation)
}
pub fn session(
&self,
operation: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
self.host.session(&self.context, operation)
}
pub async fn prepare_subjektiv_memory_surface(
&self,
) -> Result<SubjektivSurfacePrepareResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PrepareSurface(
SubjektivSurfacePrepareRequest {},
))? {
SubjektivMemoryBackendResponse::SurfacePrepared(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface preparation response".into(),
)),
}
}
pub async fn publish_subjektiv_memory_surface(
&self,
input: SubjektivSurfacePublishRequest,
) -> Result<SubjektivSurfacePublishResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::PublishSurface(input))? {
SubjektivMemoryBackendResponse::SurfacePublished(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface publication response".into(),
)),
}
}
pub async fn fail_subjektiv_memory_surface(
&self,
input: SubjektivSurfaceFailureRequest,
) -> Result<SubjektivSurfaceFailureResponse, SubjektivHostError> {
match self.memory(SubjektivMemoryBackendOperation::FailSurface(input))? {
SubjektivMemoryBackendResponse::SurfaceFailed(value) => Ok(value),
_ => Err(SubjektivHostError::Unavailable(
"unexpected surface failure response".into(),
)),
}
}
}
#[cfg(test)]
pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHostConnection {
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
config.profile.enabled = true;
config.profile.consolidation_tools = true;
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: client.workspace_id().unwrap().to_string(),
settings_revision: 1,
language: "English".into(),
})
.unwrap();
SubjektivHostConnection::new(
BackendSubjektivHost::from_resolved_config(&config, client)
.unwrap()
.unwrap(),
SubjektivHostContext {
worker_id: "worker-test".into(),
session_id: "session-1".into(),
},
)
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport,
WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext,
};
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures_util::Stream;
use session_store::{CombinedStore, FsStore, FsWorkerStore, WorkerMetadataStore};
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Default)]
struct LocalHost {
contexts: Mutex<Vec<SubjektivHostContext>>,
attributed: Mutex<Vec<(SubjektivHostContext, bool)>>,
}
impl SubjektivHost for LocalHost {
fn settings(&self) -> SubjektivHostSettings {
SubjektivHostSettings {
language: "English".into(),
}
}
fn memory(
&self,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
self.contexts.lock().unwrap().push(context.clone());
match operation {
SubjektivMemoryBackendOperation::ResidentContext(_) => {
Ok(SubjektivMemoryBackendResponse::ResidentContext(
SubjektivResidentContextOutput {
behavior_md: "Explicit local subject behavior.".into(),
behavior_revision: 1,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability:
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
content: None,
},
},
))
}
_ => Err(SubjektivHostError::Unavailable(
"not granted by test Host".into(),
)),
}
}
fn session(
&self,
_: &SubjektivHostContext,
_: SubjektivSessionBackendOperation,
) -> Result<SubjektivSessionBackendResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn stage_candidate(
&self,
_: &SubjektivHostContext,
_: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("not used".into()))
}
fn record_session(
&self,
context: &SubjektivHostContext,
create_if_missing: bool,
) -> Result<SubjektivRecordSessionResponse, SubjektivHostError> {
self.attributed
.lock()
.unwrap()
.push((context.clone(), create_if_missing));
Ok(SubjektivRecordSessionResponse {
subject_id: "test-owned-local-subject".into(),
session_id: context.session_id.clone(),
})
}
fn request_consolidation(
&self,
_: &SubjektivHostContext,
_: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
Err(SubjektivHostError::Unavailable("no candidates".into()))
}
}
#[derive(Clone)]
struct ScriptedClient(tokio::sync::mpsc::UnboundedSender<Request>);
#[async_trait]
impl LlmClient for ScriptedClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.0.send(request).unwrap();
Ok(Box::pin(futures_util::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Done.")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn launch_case(enabled: bool, attach: bool) {
let temp = tempfile::tempdir().unwrap();
let _runtime_sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let name = format!("subjektiv-host-{}", uuid::Uuid::now_v7());
let mut manifest = WorkerManifest::from_toml(&format!(
r#"
[worker]
name = "{name}"
[model]
scheme = "anthropic"
model_id = "scripted-model"
[engine]
[scope]
allow = []
[feature.task]
enabled = true
"#
))
.unwrap();
manifest.feature.subjektiv.profile.enabled = enabled;
manifest.feature.subjektiv.profile.extraction.enabled = true;
manifest.feature.subjektiv.profile.extraction.threshold = Some(u64::MAX);
assert!(manifest.feature.subjektiv.workspace_settings.is_none());
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let host = Arc::new(LocalHost::default());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let bootstrap = WorkerBootstrap::new(
manifest,
store.clone(),
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
.with_model_client(ScriptedClient(tx));
let bootstrap = if attach {
bootstrap.with_subjektiv_host(host.clone())
} else {
bootstrap
};
let prepared = bootstrap.prepare().await.unwrap();
let expected = SubjektivHostContext {
worker_id: name.clone(),
session_id: prepared.worker().session_id().to_string(),
};
assert!(
prepared
.worker()
.manifest()
.feature
.subjektiv
.workspace_settings
.is_none()
);
assert!(prepared.worker().workspace_id().is_none());
assert!(
prepared
.worker()
.workspace_client()
.workspace_id()
.is_none()
);
let controller = prepared.start().await.unwrap();
controller
.handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"Hello",
))
.await
.unwrap();
let request = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
let tools = request
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(
tools.contains(&"TaskCreate"),
"ordinary Features must survive"
);
for name in [
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryRemember",
"SubjektivSessionList",
"SubjektivSessionRead",
] {
assert_eq!(tools.contains(&name), enabled && attach, "{name}");
}
assert!(!tools.contains(&"MemoryApplyCandidate"));
let rendered = format!(
"{}\n{}",
request.system_prompt.as_deref().unwrap_or_default(),
request
.items
.iter()
.filter_map(|item| item.as_text())
.collect::<Vec<_>>()
.join("\n")
);
assert_eq!(
rendered.contains("Explicit local subject behavior."),
enabled && attach
);
if enabled && attach {
assert_eq!(
*host.attributed.lock().unwrap(),
vec![(expected.clone(), true)]
);
assert!(
host.contexts
.lock()
.unwrap()
.iter()
.all(|context| context == &expected)
);
let metadata = store.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
metadata.subjektiv_session_attribution,
Some(session_store::SubjektivSessionAttributionState::Confirmed { .. })
));
} else {
assert!(host.contexts.lock().unwrap().is_empty());
assert!(host.attributed.lock().unwrap().is_empty());
}
controller
.handle
.send(Method::Shutdown {
command: protocol::WorkerCommandEnvelope::new(1),
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(5), controller.shutdown)
.await
.unwrap()
.unwrap();
controller.controller_task.await.unwrap();
}
#[tokio::test]
async fn explicit_local_host_installs_ordinary_features_without_workspace_snapshot() {
launch_case(true, true).await;
}
#[tokio::test]
async fn enabled_policy_without_host_is_inert() {
launch_case(true, false).await;
}
#[tokio::test]
async fn disabled_policy_with_host_has_no_subjektiv_side_effects() {
launch_case(false, true).await;
}
#[test]
fn consolidation_installation_requires_separate_explicit_host_grant() {
let connection = SubjektivHostConnection::new(
Arc::new(LocalHost::default()),
SubjektivHostContext {
worker_id: "test-worker".into(),
session_id: "test-session".into(),
},
)
.unwrap();
assert!(SubjektivConsolidationToolsFeature::new(connection).is_err());
}
}
+162 -62
View File
@@ -357,6 +357,9 @@ pub(crate) fn authenticated_input_provenance(
) -> WorkerHistoryProvenance {
match source {
protocol::AuthenticatedInputSource::UntrustedWire => WorkerHistoryProvenance::LegacyUnknown,
protocol::AuthenticatedInputSource::LocalOperator => {
WorkerHistoryProvenance::LocalHumanInput
}
protocol::AuthenticatedInputSource::Account { account_id } => {
WorkerHistoryProvenance::HumanInput {
account_id: account_id.clone(),
@@ -2752,6 +2755,7 @@ pub struct Worker<C: LlmClient, St: Store> {
inject_resident_summary: bool,
/// Deferred resident prompt source installed by an enabled Feature.
feature_resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
subjektiv_host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>,
/// One-shot generic Feature lifecycle notification required after restore.
restore_feature_lifecycle_pending: bool,
/// Complete system prompt replacement installed by an enabled Feature.
@@ -3014,6 +3018,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -3233,6 +3238,49 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.workspace_context.client_handle()
}
/// Bind a live capability before Feature installation. This is deliberately
/// separate from Workspace authority and is not inherited by SubWorkers.
pub fn bind_subjektiv_host(&mut self, host: Option<Arc<dyn crate::subjektiv::SubjektivHost>>) {
self.subjektiv_host = host;
}
pub fn subjektiv_host_connection(
&self,
) -> std::io::Result<Option<crate::subjektiv::SubjektivHostConnection>> {
use crate::subjektiv::{
BackendSubjektivHost, SubjektivHostConnection, SubjektivHostContext,
};
let config = &self.manifest.feature.subjektiv;
config.validate_execution().map_err(std::io::Error::other)?;
if !config.profile.enabled {
return Ok(None);
}
let host = if let Some(host) = &self.subjektiv_host {
if config.workspace_settings.is_some()
|| self.workspace_id().is_some()
|| self.workspace_client().workspace_id().is_some()
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"explicit local subjektiv Host cannot replace trusted Backend Workspace authority",
));
}
Some(host.clone())
} else {
BackendSubjektivHost::from_resolved_config(config, self.workspace_client_handle())?
};
host.map(|host| {
SubjektivHostConnection::new(
host,
SubjektivHostContext {
worker_id: self.manifest.worker.name.clone(),
session_id: self.session_id().to_string(),
},
)
})
.transpose()
}
/// Bind the host-owned Worker-session observation projection. The provider
/// is responsible for workspace authorization and per-capture revalidation.
pub fn bind_worker_observation_provider(
@@ -7324,6 +7372,7 @@ where
prompts: common.prompts,
inject_resident_summary: true,
feature_resident_summary_source: None,
subjektiv_host: None,
restore_feature_lifecycle_pending: false,
feature_system_prompt_override: None,
user_segments: Vec::new(),
@@ -7433,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()
+2
View File
@@ -16,6 +16,8 @@ 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
File diff suppressed because it is too large Load Diff
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)]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ server-api = { workspace = true }
workdir = { workspace = true }
tokio-tungstenite = { workspace = true }
session-store = { workspace = true }
standalone = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }
tui = { workspace = true }
+4 -1
View File
@@ -27,6 +27,7 @@ pub(crate) enum CliCommand {
Plugin,
Mcp,
Session,
Subject,
Login,
}
@@ -46,6 +47,7 @@ impl CliCommand {
CliCommand::Plugin => "yoi plugin",
CliCommand::Mcp => "yoi mcp",
CliCommand::Session => "yoi session",
CliCommand::Subject => "yoi subject",
CliCommand::Login => "yoi login",
}
}
@@ -65,7 +67,8 @@ impl CliCommand {
| CliCommand::WorkerCleanup
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
| CliCommand::Session
| CliCommand::Subject => CliConnectionRequirement::LocalOnly,
}
}
}
+213 -6
View File
@@ -3,6 +3,7 @@ mod mcp_cli;
mod objective_cli;
mod plugin_cli;
mod session_cli;
mod subject_cli;
mod ticket_cli;
mod worker_cleanup_cli;
mod workspace_bootstrap;
@@ -41,6 +42,10 @@ enum Mode {
target: client::ResolvedTarget,
},
Session(session_cli::SessionCli),
Subject {
cli: subject_cli::SubjectCli,
target: Box<dyn Target>,
},
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
Ticket {
cli: ticket_cli::TicketCli,
@@ -171,6 +176,26 @@ async fn run(mode: Mode) -> ExitCode {
}
}
}
Mode::Subject { cli, target } => {
let result = if cli == subject_cli::SubjectCli::Help {
Ok(subject_cli::HELP.to_owned())
} else {
match target.spawn_worker() {
Ok(spawn) => subject_cli::run(cli, &spawn.state_dir),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
};
match result {
Ok(output) => {
print!("{output}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi subject: {error}");
ExitCode::FAILURE
}
}
}
Mode::Session(cli) => match session_cli::run(cli) {
Ok(output) => {
print!("{}", output.stdout);
@@ -406,6 +431,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
LaunchMode::Spawn {
worker_name: None,
profile: None,
subject_id: None,
}
};
return Ok(Mode::Tui {
@@ -454,6 +480,21 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Objective { cli, target });
}
"subject" => {
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"yoi subject is local-only and does not accept Backend selectors".to_owned(),
));
}
if !target_selection.explicit_local {
return Err(ParseError(
"yoi subject requires explicit --local selection".to_owned(),
));
}
let cli = subject_cli::parse(&args[1..])?;
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Subject)?;
return Ok(Mode::Subject { cli, target });
}
"session" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
let session_cli = session_cli::parse_session_args(&args[1..])
@@ -828,6 +869,19 @@ fn required_option_value<'a>(
Ok(value)
}
fn set_subject_selector(selector: &mut Option<String>, value: &str) -> Result<(), ParseError> {
if selector.is_some() {
return Err(ParseError(
"--subject must not be provided more than once".to_owned(),
));
}
if value.trim().is_empty() || value.starts_with('-') {
return Err(ParseError("--subject requires a nonempty ID".to_owned()));
}
*selector = Some(value.to_owned());
Ok(())
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
@@ -837,6 +891,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
let mut worker_name = None;
let mut session = None;
let mut profile = None;
let mut subject_id = None;
let mut socket_override = None;
let mut runtime_id = None;
let mut worker_id = None;
@@ -884,6 +939,15 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
socket_override = Some(PathBuf::from(value));
i += 2;
}
"--subject" => {
let value = required_option_value(args, i, "--subject")?;
set_subject_selector(&mut subject_id, value)?;
i += 2;
}
arg if arg.starts_with("--subject=") => {
set_subject_selector(&mut subject_id, &arg["--subject=".len()..])?;
i += 1;
}
"--profile" => {
let value = args
.get(i + 1)
@@ -989,6 +1053,24 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
}
}
if subject_id.is_some() {
if session.is_some()
|| socket_override.is_some()
|| runtime_id.is_some()
|| worker_id.is_some()
{
return Err(ParseError(
"--subject can only be used for fresh local spawn, not attach or restore"
.to_owned(),
));
}
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(ParseError(
"--subject is local-only and does not accept Backend selectors".to_owned(),
));
}
}
if worker_id.is_some() && runtime_id.is_none() {
return Err(ParseError(
"--worker-id requires --runtime-id for Runtime API attach".to_string(),
@@ -1018,12 +1100,29 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
return Err(ParseError("--socket requires --worker".to_string()));
}
let target = resolve_tui_target(
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,
..
+184
View File
@@ -0,0 +1,184 @@
use std::path::Path;
use standalone::subjektiv::StandaloneSubjects;
use super::ParseError;
pub(crate) const HELP: &str = "Local Subjects (operator-managed standalone state):
yoi --local subject create <ROLE> [--behavior-md <TEXT>]
yoi --local subject list
yoi --local --subject <ID> [--profile builtin:standalone-subjektiv]
Create prints a Subject record as JSON; list prints a JSON array.
Selection is explicit and only applies to fresh local launches.
Resume restores the original Subject binding and does not accept --subject.
";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SubjectCli {
Help,
Create {
role: String,
behavior_md: Option<String>,
},
List,
}
pub(crate) fn parse(args: &[String]) -> Result<SubjectCli, ParseError> {
match args.first().map(String::as_str) {
None => Ok(SubjectCli::Help),
Some("--help" | "-h") if args.len() == 1 => Ok(SubjectCli::Help),
Some("list") if args.len() == 1 => Ok(SubjectCli::List),
Some("create") => {
let role = args
.get(1)
.filter(|role| !role.starts_with('-') && !role.trim().is_empty())
.ok_or_else(|| ParseError("subject create requires a nonempty ROLE".to_owned()))?;
let mut behavior_md = None;
let mut i = 2;
while i < args.len() {
if behavior_md.is_some() {
return Err(ParseError(
"subject create accepts --behavior-md only once".to_owned(),
));
}
match args[i].as_str() {
"--behavior-md" => {
let text = args
.get(i + 1)
.filter(|text| !text.starts_with("--"))
.ok_or_else(|| ParseError("--behavior-md requires TEXT".to_owned()))?;
behavior_md = Some(text.clone());
i += 2;
}
arg if arg.starts_with("--behavior-md=") => {
behavior_md = Some(arg.trim_start_matches("--behavior-md=").to_owned());
i += 1;
}
arg => {
return Err(ParseError(format!(
"unknown subject create argument `{arg}`"
)));
}
}
}
Ok(SubjectCli::Create {
role: role.clone(),
behavior_md,
})
}
Some(arg) => Err(ParseError(format!(
"invalid subject command or arguments `{arg}`; use `yoi --local subject --help`"
))),
}
}
pub(crate) fn run(cli: SubjectCli, state_dir: &Path) -> Result<String, Box<dyn std::error::Error>> {
if cli == SubjectCli::Help {
return Ok(HELP.to_owned());
}
let subjects = StandaloneSubjects::open(state_dir)?;
let json = match cli {
SubjectCli::Create { role, behavior_md } => {
serde_json::to_string_pretty(&subjects.create(&role, behavior_md.as_deref())?)?
}
SubjectCli::List => serde_json::to_string_pretty(&subjects.list()?)?,
SubjectCli::Help => unreachable!(),
};
Ok(format!("{json}\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_owned()).collect()
}
#[test]
fn parse_explicit_create_and_list() {
assert_eq!(
parse(&args(&[
"create",
"assistant",
"--behavior-md",
"Remember decisions."
]))
.unwrap(),
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Remember decisions.".to_owned())
}
);
assert_eq!(parse(&args(&["list"])).unwrap(), SubjectCli::List);
}
#[test]
fn reject_missing_empty_duplicate_and_unknown_arguments() {
for input in [
vec!["create"],
vec!["create", " "],
vec!["create", "role", "--behavior-md"],
vec!["create", "role", "--behavior-md=x", "--behavior-md=y"],
vec!["list", "--subject=id"],
vec!["create", "role", "--backend=url"],
] {
assert!(parse(&args(&input)).is_err(), "{input:?}");
}
}
#[test]
fn create_and_list_share_only_the_selected_state_root() {
let root = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
// New state roots are created privately; temporary parent directories
// need not meet the state-root permission contract.
let state_dir = root.path().join("state");
let other_state_dir = other.path().join("state");
let created: serde_json::Value = serde_json::from_str(
&run(
SubjectCli::Create {
role: "assistant".to_owned(),
behavior_md: Some("Be precise.".to_owned()),
},
&state_dir,
)
.unwrap(),
)
.unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &state_dir).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([created]));
let elsewhere: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, &other_state_dir).unwrap()).unwrap();
assert_eq!(elsewhere, serde_json::json!([]));
}
#[cfg(unix)]
#[test]
fn nonprivate_existing_root_requires_explicit_operator_permission_change() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(run(SubjectCli::List, root.path()).is_err());
assert_eq!(
std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777,
0o755
);
assert!(!root.path().join("subjektiv").exists());
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let listed: serde_json::Value =
serde_json::from_str(&run(SubjectCli::List, root.path()).unwrap()).unwrap();
assert_eq!(listed, serde_json::json!([]));
}
#[test]
fn help_does_not_open_state() {
let root = tempfile::tempdir().unwrap();
let absent = root.path().join("absent");
assert_eq!(run(SubjectCli::Help, &absent).unwrap(), HELP);
assert!(!absent.exists());
}
}
+41
View File
@@ -0,0 +1,41 @@
# Standalone subjektiv (T-709)
## Implementation design, recorded before implementation
Base: `13055d7ee18f9d29edc641d0bd0e57c3d034dfa0`, including T-704 and T-708. No Backend database, fake Workspace, local fallback, new daemon, or subjektiv-specific Internal Worker runner is introduced.
The operator-managed standalone state directory (existing CLI/XDG standalone state setting, not cwd or a repository file) is the local sharing boundary. `<state_dir>/subjektiv/` is a private directory. A persisted random storage-scope identity is issued here; Subject IDs use the common domain UUID issuer, never cwd/Profile/Worker name. The common Feature SQLite manager opens the subjektiv database under this root; it owns connection options, migration execution, close/reopen and transaction locking. The common subjektiv crate owns the existing schema/migrations/domain/CAS/receipt/surface rules. Existing Backend layout and physical schema remain unchanged. Job domain state is not embedded into Memory tables or Session text.
Subjects are explicitly created/listed in that state scope and selected at normal launch; enabled policy alone does not create or attach one. Each selected Subject has a separate OS advisory lock file under `<state_dir>/subjektiv/leases/`. The Host holds the exclusive lock for body execution and all delegated Jobs until confirmed shutdown. A second process cannot acquire the same lock. Process death releases the kernel lock, not a PID/Profile guess; uncertain in-process cleanup retains ownership. A separate Subject can run concurrently, with SQLite serializing common database transactions. Symlinks and non-private state nodes must be rejected at this boundary. Knowledge of an ID/path is not a model capability.
The selected Subject and local storage-scope identity are persisted in the standalone Worker record. Restore verifies them and reacquires the original Subject lock; resume never silently changes Subjects. Subject-free and disabled-policy launches retain the old path with no subjektiv persistence side effects. Ordinary SubWorkers/Reviewers receive no independent Subject/extraction connection.
The Feature receives an explicit Host-scoped subjektiv capability, separate from WorkspaceClient and Profile policy. Backend adapts its existing authenticated API operations; standalone binds one local store, Subject, Worker and committed Session authority. Session attribution is established by the Host, including Sessions without candidates. Reads use the existing committed public Session projection and standalone Session store, not a copy in the Memory database. No system prompt, hidden reasoning, uncommitted history or arbitrary filesystem/foreign-Subject access is granted.
Consolidation uses the existing generic standalone Jobs service and common request/result contracts. For a selected Subject the Host owns `<state_dir>/subjektiv/jobs/<SubjectId>/jobs.sqlite3` under that Subject lease, allowing durable intents/unknown outcomes/results to follow it across Workers. Subject-free Hosts retain the T-708 Worker-local location. Subjektiv selects the consolidation Profile. Job capabilities are explicitly attenuated to the immutable candidate batch, recall/decision/surface operations and public Session references; they never confer the Subject body's execution lease or ambient parent filesystem/Workspace tools. Common receipt/CAS rules handle partial apply and response loss; unknown execution is never automatically replayed. Surface failure leaves confirmed Memory intact. The generic runner owns cancellation, timeout, result binding, resource cleanup and shutdown; processing stops with the standalone process and pending durable work is recovered only on a subsequent explicit Host connection.
Validation must distinguish scripted model/Host and real test-owned process/lock runs from live-provider model quality. Root `cargo check`, changed-crate tests and narrow Backend/Feature/Job regression tests are required, plus format/diff checks. Concrete CLI/UI usage and recovery are documented in [standalone.md](../standalone.md).
## Implemented boundaries and validation
- `feature-storage` owns the shared SQLite connection policy/Feature manager; `subjektiv` owns the unchanged domain schema/migrations, Host-neutral API dispatch, and T-704 consolidation request/result/surface validation. Server modules remain thin adapters; Worker/standalone do not depend on Workspace Server.
- `worker::subjektiv` exposes a nonserializable live Host capability plus a separate attenuated consolidation capability. Backend creation still requires the actual authenticated client and bound snapshot. Local creation is explicit Host injection; unavailable Backend never falls back locally.
- The local catalog keeps private scope/language settings and common Feature storage under `subjektiv/`, separately from generic Subject Job ledgers. The common attribution table's existing `runtime_id` lookup key is the local storage namespace for local rows, not a running Runtime identity; public local evidence leaves `runtime_id`, Workspace, and Account fields absent. Worker IDs, Subject binding, and operator-managed scope must all match before Session content is read.
- The shared strict FsStore public index is also used for committed evidence admission. Stable references are intersected with visible committed entries; numeric hints accept the two existing capture producers. Open-Run evidence is deferred only before a request, never exposed by Session tools or recorded before commit. Persisted local human provenance is distinct from Backend Account provenance and maps to the existing public `HumanInput` class.
- Standalone integration tests use real Hosts/SQLite, scripted extraction/consolidation/surface models, and test-owned second processes. Nine cases cover inert/disabled policy, reopen, saved-binding restore, cross-Worker recall/source discovery, exact explicit commit staging, normal extract/pointer → generic Job → CAS apply → resident surface, same-Subject cross-process exclusion/abnormal-exit recovery, and transport loss after apply followed by surface-only repair without duplicate Memory revision.
- Generic Job tests additionally cover immutable grant persistence, missing/foreign/revision/consumer grants, per-attempt/deadline revocation, cancellation, timeout, cleanup failure, and stale capabilities. Session adapter tests cover no-candidate discovery, foreign Subject/scope/Worker denial, bounded UTF-8 and JSON output, filter/generation-bound cursors, missing/corrupt storage, and hidden/uncommitted/partial-tail exclusion without observation writes.
Verified commands:
```sh
cargo test -p standalone --test subjektiv -- --test-threads=1
cargo test -p protocol -p feature-storage -p subjektiv -p manifest -p session-store -p standalone -p worker -p tui -p yoi -p yoi-workspace-server
cargo test -p worker-runtime --lib protocol_
cargo check
cargo check -p protocol --features typescript,json-schema
cargo check -p tui --features e2e-test
cargo fmt --all -- --check
git diff --check HEAD
```
The changed-crate run passed, including 836 Worker unit tests, 603 Workspace Server unit tests (authorization/singleton/T-704 regression), 48 standalone unit tests, and nine standalone Subject integration tests. Existing ignored tests remain ignored. TypeScript generation check reports existing unsupported serde-attribute warnings but succeeds. These commands prove mechanism/authorization/storage contracts, not live-model quality or full interactive terminal E2E behavior.
+52
View File
@@ -0,0 +1,52 @@
# Standalone local Subjects
Standalone runs one client-owned Worker in the current process, without a Backend or Workspace. Local Subjects are explicitly created and selected; enabling a subjektiv Profile alone neither creates nor attaches a Subject.
## Create and list
```sh
yoi --local subject create assistant --behavior-md 'Keep durable decisions and preferences.'
yoi --local subject list
```
`create <ROLE>` prints the created Subject record as JSON. `--behavior-md <TEXT>` is optional inline text, not a file path. `list` prints a JSON array of records in the selected standalone state scope. Both commands require explicit `--local` and reject Backend/Workspace selectors.
The state directory is obtained from the same local connection target as normal standalone launch. Its base follows the existing `YOI_DATA_DIR`, `YOI_HOME`, `XDG_DATA_HOME`, or `HOME` resolution, with `client/standalone/workers` appended. Subjects live under `<state_dir>/subjektiv/`; cwd, repository files, Worker names, and Profiles are not Subject identity or sharing boundaries. Use the returned common-domain Subject ID (`subject-<UUID>`), not the role, for selection.
The user-managed state root must be operator-owned and private (mode **0700**). A missing root is created with mode 0700; an existing root is validated, not automatically chmodded. An older nonprivate root is rejected. The operator can explicitly run `chmod 700 <state_dir>` on their own state root before retrying; do not substitute a repository directory or assume the CLI repairs permissions. Symlink and nonprivate Subject storage nodes are also rejected.
## Select for a new Worker
```sh
yoi --local --subject <ID> --profile builtin:standalone-subjektiv
```
`--subject=<ID>` is also accepted. The selector must be nonempty and may only be supplied once. It applies only to fresh local spawn: Backend selection, Runtime attach, socket/session attach, and resume combinations are rejected. A selected Subject requires a compatible subjektiv policy; launch validation rejects incompatible policy before Worker startup side effects.
The interactive local spawn form has an optional `subject` field, initially `(none)`. Press **F2** to switch between editing the Worker name and Subject ID; **Tab/Up/Down** continue to cycle Profiles. Leave the Subject field empty for the existing Subject-free launch path. The form does not create Subjects or open Subject storage just to display the field. The optional built-in `builtin:standalone-subjektiv` Profile enables local Subject behavior; its consolidation Profile is `builtin:standalone-subjektiv-consolidation` and does not require Backend resources.
## Resume
```sh
yoi --local resume
```
Resume restores the original persisted Subject/storage-scope binding and reacquires its execution lease. It does not accept a new `--subject` selector or silently change Subjects. The Host holds an exclusive per-Subject kernel lease for body execution and delegated Jobs until confirmed shutdown; one Subject cannot be executed concurrently by two processes. Process death releases the kernel lease, while uncertain in-process cleanup retains ownership. Separate Subjects may run concurrently in the same state scope.
## Subject Jobs and recovery
A selected Subject uses `<state_dir>/subjektiv/jobs/<SubjectId>/jobs.sqlite3`, shared across Worker IDs for that Subject and protected by its exclusive execution lease. Subject-free Hosts retain the existing Worker-local Job location. There is no separate Subject runner or background daemon.
On explicit selected-Subject Host startup, reserved Jobs resume automatically. An interrupted Job whose execution outcome is unknown is **never automatically replayed**. A successful result that has not yet been acknowledged is delivered from durable storage without retrying the model. Recovery is driven by an explicit Host startup, not an automatic process restart or independent scheduler.
## Memory and historical Sessions
The normal body Profile has recall, revision reads, explicit candidate tools, and bounded historical Session exploration. It cannot directly apply Memory. Explicit Remember/Revise without `entry_refs` returns a pending-commit receipt; the Host stages its exact tool-call evidence only after the logical Run is append-only committed. Pre-request retry may defer an open Run, but commit/rewrite failures are not silently treated as success. A candidate remains unconfirmed until the restricted consolidation Job applies or rejects it using the shared CAS/receipt rules.
Normal extraction uses the existing committed-capture lifecycle, restricted extraction tools, and append-only `subjektiv.extract.v1` pointer. The selected Profile owns extraction thresholds/model policy. An explicit extraction model override chooses that provider, not an injected dialog transport. Local Memory language defaults to English and is stored in operator-managed `subjektiv/language.json`, never loaded from repository policy files.
The Host stamps human input on its owned in-process client channel as persisted `local_human_input`, projected as the existing `HumanInput` provenance. It neither invents a Backend account nor trusts serialized source claims. Ordinary SubWorker/Job input retains its own nonhuman origin. Local evidence has no Workspace/account/Runtime identity; local storage-scope identity and Worker binding authorize discovery separately. Historical Sessions are not copied into SQLite. Reads/search exclude system prompts, hidden reasoning, incomplete logical Runs, partial JSONL tails, and foreign bindings; cursor continuations revalidate scope/Subject/Worker attribution and committed generation.
## Validation boundary
Host injection tests pass a clone of the injected model transport into delegated Jobs, so scripted body and Job calls use the same test-owned transport. These are local contract tests, not live-provider validation or evidence of model/consolidation quality. Generic Job finalizer and Host integration proofs are validated separately from the CLI/parser and spawn-form tests.
@@ -0,0 +1,8 @@
import "./job.dcdl" // {
slug = "standalone-subjektiv-consolidation";
description = "Subject consolidation policy; local Host grants a bounded batch, not body authority.";
engine = { instruction = "internal.subjektiv_memory_consolidation_system"; };
feature = {
subjektiv = { enabled = true; consolidation_tools = true; extraction = { enabled = false; }; };
};
}
@@ -0,0 +1,7 @@
import "./standalone.dcdl" // {
slug = "standalone-subjektiv";
description = "Standalone coding with an explicitly selected local Subject.";
feature = {
subjektiv = { enabled = true; extraction = { enabled = true; }; };
};
}