fix: integrate develop and preserve invocation notification recovery

This commit is contained in:
2026-10-05 22:25:32 +09:00
92 changed files with 14528 additions and 1005 deletions
+1
View File
@@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
eventsource-stream = "0.2"
zstd = "0.13"
base64 = "0.22.1"
imagesize = "0.14"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
agen-macros = { workspace = true }
+45 -1
View File
@@ -626,6 +626,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// engine history. An error rejects the item and aborts the turn, allowing
/// upper layers to make durable storage the commit gate.
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
/// Durable host commit gate for image-result corrections. Runs before the
/// correction becomes live or a replacement request can be sent.
image_rejection_handler:
Option<Box<dyn Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig,
/// Cancel notification channel (for interrupting execution)
@@ -971,6 +975,16 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
self.history_append_cbs.push(Box::new(callback));
}
/// Set the host's durable commit gate for correcting a rejected ViewImage
/// result. The original annotation/identity must be preserved. A failed
/// commit leaves both history and the outgoing request untouched.
pub fn set_image_rejection_handler(
&mut self,
handler: impl Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync + 'static,
) {
self.image_rejection_handler = Some(Box::new(handler));
}
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
for cb in &self.history_append_cbs {
cb(item).map_err(EngineError::HistoryAppend)?;
@@ -3205,7 +3219,34 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
"Sending request to LLM"
);
let stream = self.open_stream_with_retry(request, turn, llm_call).await?;
let mut request = request;
let stream = loop {
match self
.open_stream_with_retry(request.clone(), turn, llm_call)
.await
{
Ok(stream) => break stream,
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
let Some((history_index, request_index)) =
crate::image_recovery::largest_candidate(history, &request)
else {
return Err(EngineError::Client(error));
};
let entry = &history.entries()[history_index];
let replacement = crate::image_recovery::rejected_result(&entry.item);
if let Some(commit) = &self.image_rejection_handler {
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
}
history.entries_mut()[history_index].item = replacement.clone();
// Rebuild the provider request from the corrected projection,
// retaining prior prune/interceptor decisions. No tool runs
// or assistant output have occurred for this failed request.
request.items[request_index] = replacement;
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
}
Err(error) => return Err(error),
}
};
self.tool_call_collector.begin_response();
let mut stream = ResponseStreamPump::start(stream);
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
@@ -3567,6 +3608,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: Vec::new(),
tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(),
image_rejection_handler: None,
request_config: RequestConfig::default(),
cancel_tx,
cancel_rx,
@@ -3856,6 +3898,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
@@ -4040,6 +4083,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
+106
View File
@@ -0,0 +1,106 @@
//! Recovery is driven by provider rejection, never a locally chosen image limit.
use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment};
pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again.";
/// Keep the most recent response's tool results eligible, including early tool
/// results interleaved with that response's later text/calls. Empty boundaries
/// from older versions' failed requests are not evidence of accepted input.
pub(crate) fn accepted_input_end<'a>(items: impl Iterator<Item = &'a Item>) -> Option<usize> {
let mut response_start = None;
let mut accepted_prefix = None;
for (index, item) in items.enumerate() {
match item {
Item::AssistantResponseBoundary { .. } => response_start = Some(index),
Item::Message {
role: Role::Assistant,
..
}
| Item::ToolCall { .. }
| Item::Reasoning { .. } => {
accepted_prefix = Some(response_start.unwrap_or(index));
}
Item::Message {
role: Role::User, ..
} => response_start = None,
_ => {}
}
}
accepted_prefix
}
/// Select only a ViewImage result actually sent in the rejected request. The
/// tool contract returns exactly one image, so each correction removes one
/// image and preserves the tool call/result pair. Other tools and user uploads
/// are deliberately not rewritten by this recovery policy.
pub(crate) fn largest_candidate<A>(
history: &History<A>,
request: &Request,
) -> Option<(usize, usize)> {
let start = accepted_input_end(history.items()).unwrap_or(0);
let mut largest = None;
for (index, entry) in history.entries().iter().enumerate().skip(start) {
let Item::ToolResult {
call_id,
attachments,
..
} = &entry.item
else {
continue;
};
let [Attachment::Image(image)] = attachments.as_slice() else {
continue;
};
let from_view_image = history.items().any(|item| {
matches!(
item,
Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage"
)
});
if !from_view_image {
continue;
}
let sent = request.items.iter().position(|item| {
matches!(
item,
Item::ToolResult { call_id: id, attachments: sent, .. }
if id == call_id && sent == attachments
)
});
let Some(request_index) = sent else { continue };
// Header inspection only; no raster allocation or size threshold.
// Unknown dimensions sort by bytes as a deterministic fallback.
let pixels = imagesize::blob_size(image.data())
.ok()
.map(|size| (size.width as u64).saturating_mul(size.height as u64))
.unwrap_or(0);
let rank = (pixels, image.data().len(), index);
if largest
.as_ref()
.is_none_or(|(previous, _)| rank > *previous)
{
largest = Some((rank, (index, request_index)));
}
}
largest.map(|(_, candidate)| candidate)
}
pub(crate) fn rejected_result(item: &Item) -> Item {
let mut item = item.clone();
if let Item::ToolResult {
summary,
content,
attachments,
disposition,
is_error,
..
} = &mut item
{
*summary = REJECTION_MESSAGE.to_owned();
*content = None;
attachments.clear();
*disposition = ToolResultDisposition::Error;
*is_error = true;
}
item
}
+1
View File
@@ -3,6 +3,7 @@
mod engine;
mod handler;
mod history;
mod image_recovery;
mod message;
pub(crate) mod callback;
+29
View File
@@ -90,6 +90,35 @@ impl ClientError {
}
}
/// A provider-declared image size rejection, not a generic invalid input.
/// Some providers (including OpenAI) expose only `invalid_value` here, so
/// narrowly recognize their image-size diagnostic without hardcoded limits.
pub fn is_image_size_rejection(&self) -> bool {
let Self::Api {
status: Some(400 | 413 | 422),
code,
message,
..
} = self
else {
return false;
};
if matches!(
code.as_deref(),
Some("image_too_large" | "image_size_exceeded")
) {
return true;
}
let message = message.to_ascii_lowercase();
message.contains("image")
&& ((message.contains("patches")
&& message.contains("exceed")
&& message.contains("limit"))
|| message.contains("image is too large")
|| message.contains("image too large")
|| (message.contains("image dimensions") && message.contains("exceed")))
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ClientError::Api { retry_after, .. } => *retry_after,
+6 -7
View File
@@ -136,14 +136,13 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
/// Remove binary attachments after the first model request that could consume them.
///
/// Attachments on tool results following the latest model output are fresh outputs for the
/// immediate follow-up request and remain visible. Once a later assistant/reasoning/tool-call
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
/// requests keep the durable tool summary and can call the originating tool again when the image
/// is still needed. Only the request-context clone is modified.
/// immediate follow-up request and remain visible. With early dispatch, later
/// text/calls in the same response have not consumed its tool results: use the
/// confirmed response boundary, not that response's last individual item.
/// Subsequent requests keep the durable tool summary and can call the originating
/// tool again when the image is still needed. Only the request-context clone is modified.
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
let latest_model_output = items.iter().rposition(|item| {
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
});
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
let mut count = 0;
for (index, item) in items.iter_mut().enumerate() {
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
+350
View File
@@ -0,0 +1,350 @@
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
fn rejection() -> ClientError {
ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None }
}
fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item {
// PNG header: dimension inspection never decodes/allocates the pixel plane.
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend(width.to_be_bytes());
bytes.extend(height.to_be_bytes());
bytes.resize(40 + padding, 0);
Item::ToolResult {
id: None,
call_id: id.into(),
summary: "Attached image".into(),
content: None,
disposition: ToolResultDisposition::Success,
is_error: false,
attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))],
}
}
fn history() -> History {
History::from_items(vec![
Item::user_message("inspect"),
Item::tool_call("small", "ViewImage", "{}"),
Item::tool_call("large", "ViewImage", "{}"),
image_result("small", 10, 10, 2000),
image_result("large", 1600, 33349, 0),
])
}
fn images(request: &Request) -> Vec<String> {
request
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult {
call_id,
attachments,
..
} if !attachments.is_empty() => Some(call_id.clone()),
_ => None,
})
.collect()
}
#[derive(Clone)]
struct Client {
requests: Arc<Mutex<Vec<Request>>>,
failures: usize,
other_error: bool,
}
impl Client {
fn new(failures: usize) -> Self {
Self {
requests: Arc::default(),
failures,
other_error: false,
}
}
}
#[async_trait]
impl LlmClient for Client {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let mut requests = self.requests.lock().unwrap();
requests.push(request);
if requests.len() <= self.failures {
return Err(rejection());
}
if self.other_error {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "invalid tool arguments".into(),
retry_after: None,
});
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "resize and retry")),
Ok(Event::text_block_stop(0, None)),
Ok(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
#[tokio::test]
async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
let mut engine = Engine::new(client);
let committed = Arc::new(Mutex::new(Vec::new()));
let commits = committed.clone();
engine.set_image_rejection_handler(move |original, replacement| {
commits
.lock()
.unwrap()
.push((original.item.clone(), replacement.clone()));
Ok(())
});
let output = engine.run(&mut history, "continue").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(images(&requests[0]), ["small", "large"]);
assert_eq!(images(&requests[1]), ["small"]);
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage"))));
assert_eq!(committed.lock().unwrap().len(), 1);
assert_eq!(
history
.items()
.filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large"))
.count(),
1
);
}
#[tokio::test]
async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["small"]);
assert!(images(&requests[2]).is_empty());
}
#[tokio::test]
async fn unrelated_error_stops_recovery_without_removing_another_image() {
let mut client = Client::new(1);
client.other_error = true;
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(observer.requests.lock().unwrap().len(), 2);
assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty())));
}
#[tokio::test]
async fn persistence_failure_does_not_change_history_or_retry() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let original = history.items_cloned();
let mut engine = Engine::new(client);
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
let out = engine.run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
assert_eq!(observer.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn previously_accepted_images_are_not_recovery_candidates() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_message("I saw those images"));
history.push(Item::tool_call("new", "ViewImage", "{}"));
history.push(image_result("new", 1, 1, 0));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
// Already-consumed images are projected out, but their durable records
// must not be rewritten as failures.
assert!(images(&requests[1]).is_empty());
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2);
}
#[tokio::test]
async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_response_boundary("failed-1"));
history.push(Item::assistant_response_boundary("failed-2"));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]);
}
#[tokio::test]
async fn early_results_remain_candidates_despite_later_text_in_same_response() {
let client = Client::new(1);
let observer = client.clone();
let mut history = History::from_items(vec![
Item::user_message("inspect"),
Item::assistant_response_boundary("response-1"),
Item::tool_call("image", "ViewImage", "{}"),
image_result("image", 99, 99, 0),
Item::assistant_message("waiting for image"),
]);
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}; requests: {:?}",
out.result,
observer.requests.lock().unwrap()
);
assert!(images(&observer.requests.lock().unwrap()[1]).is_empty());
}
#[derive(Clone)]
struct CountingTool {
image: bool,
count: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl agen::tool::Tool for CountingTool {
async fn execute(
&self,
_: &str,
_: agen::tool::ToolExecutionContext,
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let attachments = if self.image {
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
unreachable!()
};
attachments
} else {
Vec::new()
};
Ok(agen::tool::ToolOutput {
summary: "executed once".into(),
content: None,
attachments,
})
}
}
#[derive(Clone)]
struct ToolClient(Client);
#[async_trait]
impl LlmClient for ToolClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
agen::llm_client::ToolCallCompletionSupport::PerBlock
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
if self.0.requests.lock().unwrap().is_empty() {
self.0.requests.lock().unwrap().push(request);
return Ok(Box::pin(futures::stream::iter(
vec![
Event::tool_use_start(0, "image", "ViewImage"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::tool_use_start(1, "side-effect", "Bash"),
Event::tool_input_delta(1, "{}"),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
.into_iter()
.map(Ok),
)));
}
self.0.stream(request).await
}
}
#[tokio::test]
async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
for mode in [
agen::ToolCallDispatchMode::AfterResponse,
agen::ToolCallDispatchMode::OnToolCallComplete,
] {
let client = Client::new(2);
let mut engine = Engine::new(ToolClient(client.clone()));
engine.set_tool_call_dispatch_mode(mode);
let mut counts = Vec::new();
for (name, image) in [("ViewImage", true), ("Bash", false)] {
let tool = CountingTool {
image,
count: Arc::default(),
};
counts.push(tool.count.clone());
engine.register_tool(Arc::new(move || {
(
agen::tool::ToolMeta::new(name)
.description("test")
.input_schema(serde_json::json!({"type":"object"})),
Arc::new(tool.clone()),
)
}));
}
let mut history = History::new();
let out = engine.run(&mut history, "inspect").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}",
out.result
);
assert!(
counts
.iter()
.all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1)
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["image"]);
assert!(images(&requests[2]).is_empty());
}
}
#[test]
fn image_error_classifier_is_not_a_generic_400_retry() {
assert!(rejection().is_image_size_rejection());
for (status, message) in [
(400, "invalid image format"),
(400, "image URL not found"),
(400, "context window exceeds token limit"),
(401, "image is too large"),
(500, "image is too large"),
] {
assert!(
!ClientError::Api {
status: Some(status),
code: Some("invalid_value".into()),
message: message.into(),
retry_after: None
}
.is_image_size_rejection()
);
}
}
+2
View File
@@ -25,6 +25,8 @@ Does not own:
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
## See also
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
+116 -4
View File
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
/// enabled without granting authority.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit Model Context Protocol provider declarations. Config parsing
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
other.manage_workdir,
FeatureFlagConfigPartial::merge,
),
workdir_catalog: merge_option(
self.workdir_catalog,
other.workdir_catalog,
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.manage_workdir
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
workdir_catalog: FeatureFlagConfig {
enabled: value
.workdir_catalog
.and_then(|flag| flag.enabled)
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
},
ticket: value
.ticket
.map(TicketFeatureConfig::from)
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
workdir_catalog: Some(value.workdir_catalog.into()),
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
/// Base config populated with the in-code per-field defaults listed in
/// [`crate::defaults`]. This is not a selectable Profile and does not
/// enable a launch capability surface. Profile and one-file Manifest
/// enable launch authority or normal Tools. The read-only WIP workdir
/// catalog defaults enabled independently. Profile and one-file Manifest
/// resolvers start from this layer so every per-field default lives at
/// exactly one call site (the `defaults` module).
///
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.profile.enabled);
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.workdir_catalog.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
}
#[test]
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
assert!(FeatureConfig::default().workdir_catalog.enabled);
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
let partial = WorkerManifestConfig::from_toml(source).unwrap();
let feature = FeatureConfig::from(partial.feature);
assert!(feature.workdir_catalog.enabled, "{source}");
let value: toml::Value = toml::from_str(source).unwrap();
let resolved: FeatureConfig = serde_json::from_value(
serde_json::to_value(
value
.get("feature")
.cloned()
.unwrap_or_else(|| toml::Value::Table(Default::default())),
)
.unwrap(),
)
.unwrap();
assert_eq!(feature, resolved);
}
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
assert!(feature.workdir_catalog.enabled);
assert!(!feature.manage_workdir.enabled);
}
}
#[test]
fn workdir_catalog_is_independent_and_merges_partial_layers() {
for catalog in [false, true] {
for manage in [false, true] {
let source = format!(
"[feature.workdir_catalog]\nenabled = {catalog}\n\
[feature.manage_workdir]\nenabled = {manage}\n"
);
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
let override_source =
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower)
.merge(upper)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
}
}
}
#[test]
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
for enabled in [false, true] {
let mut cfg = minimal_valid();
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
enabled: Some(enabled),
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
let partial = FeatureConfigPartial::from(manifest.feature.clone());
assert_eq!(
partial.workdir_catalog.as_ref().unwrap().enabled,
Some(enabled)
);
let serialized = toml::to_string(&partial).unwrap();
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
assert_eq!(FeatureConfig::from(partial), manifest.feature);
let serialized = serde_json::to_value(&manifest.feature).unwrap();
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(feature, manifest.feature);
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
let restored =
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
assert!(!restored.feature.manage_workdir.enabled);
let mut legacy = snapshot;
legacy["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("workdir_catalog");
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
assert!(restored.feature.workdir_catalog.enabled);
assert!(!restored.feature.manage_workdir.enabled);
}
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = WorkerManifestConfig::from_toml(
+4
View File
@@ -6,6 +6,10 @@
//! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line.
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
/// This enables a reference provider, not normal Tools or any authority grant.
pub const WORKDIR_CATALOG_ENABLED: bool = true;
/// Byte-size cap applied to any tool's `content` output when no
/// per-tool override is set. See [`crate::ToolOutputLimits`].
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+29 -2
View File
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
/// disabled; the read-only WIP workdir catalog defaults enabled without
/// granting authority or registering normal Tools.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit external Model Context Protocol provider configuration. This
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
pub objective: FeatureFlagConfig,
#[serde(default)]
pub manage_workdir: FeatureFlagConfig,
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
/// Independent of `manage_workdir`; permissions and host authority remain
/// separate, and discovering a reference grants no attachment or file access.
#[serde(
default = "default_workdir_catalog",
deserialize_with = "deserialize_workdir_catalog"
)]
pub workdir_catalog: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
pub orchestration: FeatureFlagConfig,
}
fn default_workdir_catalog() -> FeatureFlagConfig {
FeatureFlagConfig {
enabled: defaults::WORKDIR_CATALOG_ENABLED,
}
}
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
where
D: serde::Deserializer<'de>,
{
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
Ok(FeatureFlagConfig {
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
})
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
workspace_worker_discovery: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
workdir_catalog: default_workdir_catalog(),
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
+55
View File
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
if feature.flow.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Flow);
}
// workdir_catalog is a WIP-only reference provider, not Workspace authority
// or normal Tools. It must not prevent standalone Tools-mode launches.
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
@@ -1586,6 +1588,59 @@ mod tests {
assert!(!resolved.manifest.feature.manage_workdir.enabled);
}
#[test]
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
let tmp = TempDir::new().unwrap();
for catalog in [None, Some(false), Some(true)] {
let mut artifact = serde_json::json!({
"worker": { "mode": "tools" },
"feature": { "manage_workdir": { "enabled": false } },
"permissions": { "default_action": "deny" }
});
if let Some(enabled) = catalog {
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
}
let resolved = resolve_profile_artifact_value(
artifact,
ProfileSource::Archive {
archive_id: "catalog-test".into(),
source: "test".into(),
},
tmp.path(),
"catalog-worker",
)
.unwrap();
let manifest = resolved.manifest;
assert_eq!(
manifest.feature.workdir_catalog.enabled,
catalog.unwrap_or(true)
);
assert!(!manifest.feature.manage_workdir.enabled);
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert_eq!(
manifest.permissions.as_ref().unwrap().default_action,
crate::ToolPermissionAction::Deny
);
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
.unwrap();
let mut manage_manifest = manifest;
manage_manifest.feature.manage_workdir.enabled = true;
let error = validate_profile_execution_target(
&manage_manifest,
ProfileExecutionTarget::Standalone,
)
.unwrap_err();
assert!(matches!(
error,
ProfileError::UnsupportedExecutionTarget { requirements, .. }
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
));
}
}
#[test]
fn standalone_rejects_profiles_that_require_workspace_authority() {
let tmp = TempDir::new().unwrap();
+277 -3
View File
@@ -190,8 +190,14 @@ impl_openapi_schema!(
WorkingDirectoryListResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryCreateResponse,
CurrentWorkerWorkdirCatalogQuery,
CurrentWorkerWorkdirCatalogResponse,
CurrentWorkerWorkdirAttachRequest,
CurrentWorkerWorkdirAttachmentResponse,
CurrentWorkerWorkdirAttachmentItem,
CurrentWorkerWorkdirAttachmentListQuery,
CurrentWorkerWorkdirAttachmentListResponse,
CurrentWorkerWorkdirDetachQuery,
CurrentWorkerWorkdirOperationRequest,
CurrentWorkerWorkdirOperationResponse,
ExternalWorkdirGrantCreateRequest,
@@ -237,6 +243,7 @@ impl_openapi_schema!(
MemoryConsolidateStagingRequest,
MemoryConsolidationResponse,
SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectListQuery,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -1163,6 +1170,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn subjektiv_subject_behavior_update(
&self,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
status = 200,
@@ -2549,6 +2570,32 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-catalog",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_catalog(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirCatalogQuery,
) -> Result<CurrentWorkerWorkdirCatalogResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_attachment_list(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirAttachmentListQuery,
) -> Result<CurrentWorkerWorkdirAttachmentListResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
@@ -2574,6 +2621,7 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] alias: String,
#[query] query: CurrentWorkerWorkdirDetachQuery,
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
@@ -4579,9 +4627,20 @@ pub enum SubjektivSubjectState {
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest {
pub role: String,
#[serde(default)]
pub behavior_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectBehaviorUpdateRequest {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub expected_behavior_revision: u64,
pub behavior_md: String,
}
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -4609,6 +4668,9 @@ pub struct SubjektivSubjectListResponse {
pub struct SubjektivSubjectResponse {
pub id: String,
pub role: String,
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
pub state: SubjektivSubjectState,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64,
@@ -5442,10 +5504,22 @@ pub struct SubjektivSurfaceFailureResponse {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivResidentContextOutput {
/// Exact user-managed document. Empty means explicitly unset.
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
/// Independently-fresh generated Memory projection.
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendOperation {
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
Query(SubjektivMemoryQueryRequest),
Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest),
@@ -5618,6 +5692,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendResponse {
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
ResidentContext(SubjektivResidentContextOutput),
Query(SubjektivMemoryQueryResponse),
Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse),
@@ -6754,6 +6829,69 @@ pub struct CurrentWorkerWorkdirAttachRequest {
pub working_directory_id: String,
}
/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogQuery {
/// Defaults to 50; valid range is 1..=100 scanned registry records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
/// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub next_cursor: Option<String>,
/// Opaque digest of the complete authoritative inventory and its occupancy.
pub revision: String,
}
/// Bounded, alias-ordered paging over the current Worker's active attachments.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
/// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentItem {
pub alias: String,
pub working_directory_id: String,
pub capabilities: workdir::WorkdirSessionCapabilities,
/// Opaque durable identity of this attachment lifetime, not a Workdir ID.
pub connection_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListResponse {
pub workspace_id: String,
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
pub next_offset: Option<u32>,
/// Opaque digest of this caller's complete active connection set, independent of query paging/filtering.
pub revision: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirDetachQuery {
/// When omitted, retain the normal Tool's unconditional detach contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentResponse {
@@ -9869,9 +10007,11 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection.
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -9904,8 +10044,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
let limits = format!(
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
);
let subjektiv_limits = format!(
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
);
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
@@ -11938,6 +12081,135 @@ mod tests {
);
}
#[test]
fn current_worker_attachment_contract_keeps_normal_tool_response_closed() {
let lookup = CurrentWorkerWorkdirAttachmentListQuery {
limit: Some(1),
offset: None,
connection_id: Some("opaque-connection".to_string()),
};
let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"});
assert_eq!(serde_json::to_value(&lookup).unwrap(), value);
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(value).unwrap(),
lookup
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"connection_id": 123})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"expected_connection_id": "wrong-field"})
)
.is_err()
);
let query: CurrentWorkerWorkdirDetachQuery =
serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(query.expected_connection_id, None);
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
assert!(
serde_json::from_value::<CurrentWorkerWorkdirDetachQuery>(
serde_json::json!({"connection_id":"wrong-field"})
)
.is_err()
);
let response = CurrentWorkerWorkdirAttachmentResponse {
workspace_id: "workspace".to_string(),
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
attached: false,
};
let value = serde_json::to_value(&response).unwrap();
assert_eq!(value.as_object().unwrap().len(), 5);
assert!(value.get("connection_id").is_none());
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentResponse>(value).unwrap(),
response
);
let list = CurrentWorkerWorkdirAttachmentListResponse {
workspace_id: "workspace".to_string(),
revision: "opaque-revision".to_string(),
next_offset: Some(1),
items: vec![CurrentWorkerWorkdirAttachmentItem {
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
connection_id: "opaque".to_string(),
}],
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::to_value(&list).unwrap()
)
.unwrap(),
list
);
}
#[test]
fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() {
let query = CurrentWorkerWorkdirCatalogQuery {
limit: Some(100),
cursor: Some("stable-id".to_string()),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::to_value(&query).unwrap()
)
.unwrap(),
query
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"offset": 0})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"limit": -1})
)
.is_err()
);
let response = CurrentWorkerWorkdirCatalogResponse {
workspace_id: "workspace".to_string(),
items: Vec::new(),
next_cursor: Some("scanned-legacy-row".to_string()),
revision: "opaque".to_string(),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::to_value(&response).unwrap()
)
.unwrap(),
response
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null})
)
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
@@ -12942,6 +13214,8 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"current_worker_workdir_catalog",
"current_worker_workdir_attachment_list",
"current_worker_workdir_attach",
"current_worker_workdir_detach",
"current_worker_workdir_operation",
+1
View File
@@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
+297 -31
View File
@@ -7,7 +7,7 @@ use protocol::{
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use thiserror::Error;
@@ -139,6 +139,17 @@ struct RetainedHistoryCursor {
before_offset: u64,
record_end_offset: Option<u64>,
seed_entry_index: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
corrections: Vec<ToolCorrectionPosition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct ToolCorrectionPosition {
entry_id: String,
segment_id: SegmentId,
start: u64,
end: u64,
}
#[derive(Debug)]
@@ -445,6 +456,7 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut Vec::new(),
)?;
Some(SessionHistoryLineageBoundary {
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
@@ -456,6 +468,10 @@ pub fn read_retained_session_history_page(
} else {
None
};
let mut correction_positions = cursor
.as_ref()
.map(|c| c.corrections.clone())
.unwrap_or_default();
let mut turns = read_history_turns_backward(
&store,
active.session_id,
@@ -465,11 +481,23 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut correction_positions,
)?;
let has_more = turns.len() > page_limit;
if has_more {
turns.truncate(page_limit);
}
// Carry only corrections whose original results are still on older pages.
let returned_ids: HashSet<_> = turns
.iter()
.flat_map(|turn| {
turn.turn
.entries
.iter()
.map(|entry| entry.entry_id.as_str())
})
.collect();
correction_positions.retain(|position| !returned_ids.contains(position.entry_id.as_str()));
let next_cursor = if has_more {
let oldest = turns
.last()
@@ -485,6 +513,7 @@ pub fn read_retained_session_history_page(
before_offset: oldest.position.before_offset,
record_end_offset: oldest.position.record_end_offset,
seed_entry_index: oldest.position.seed_entry_index,
corrections: correction_positions,
})?)
} else {
None
@@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
@@ -814,10 +844,56 @@ fn read_history_turns_backward(
limits: RetainedHistoryReadLimits,
scanned_bytes: &mut u64,
scanned_entries: &mut usize,
correction_positions: &mut Vec<ToolCorrectionPosition>,
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
let mut turns = Vec::new();
let mut pending_entries = Vec::new();
let mut seen_entries = HashSet::new();
let mut corrections = HashMap::<String, SessionSnapshotEntryData>::new();
// Carry only bounded log positions, never correction text or image bodies,
// across pages. Re-read and validate the referenced records from adopted
// lineage rather than rescanning an arbitrarily large newer suffix.
for position in correction_positions.iter() {
let segment = lineage
.iter()
.find(|s| s.segment_id == position.segment_id)
.ok_or(RetainedHistoryReadError::InvalidCursor)?;
let adopted_end = locate_adopted_segment_end(
store,
session_id,
segment,
limits,
scanned_bytes,
scanned_entries,
)?;
if position.start >= position.end || position.end > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
let record = store
.read_log_record_range_read_only_bounded(
session_id,
position.segment_id,
position.start,
position.end,
limits.max_scan_bytes.saturating_sub(*scanned_bytes),
)
.map_err(map_history_cursor_store_error)?;
*scanned_bytes = scanned_bytes
.checked_add(record.end_offset - record.start_offset)
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
*scanned_entries = scanned_entries
.checked_add(persisted_entry_units(std::slice::from_ref(&record.entry)))
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
if *scanned_entries > limits.max_entries {
return Err(RetainedHistoryReadError::ResourceLimit);
}
if !matches!(&record.entry, LogEntry::ToolResultCorrected { entry, .. } if entry.metadata.entry_id.0 == position.entry_id)
|| !capture_tool_correction(&record.entry, &mut corrections)
{
return Err(RetainedHistoryReadError::InvalidCursor);
}
}
let (start_segment_index, start_position, cursor_turn_id) = match start {
Some((position, turn_id)) => {
@@ -878,32 +954,6 @@ fn read_history_turns_backward(
for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) {
let starting_here = segment_index == start_segment_index;
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&segment.seed_entries[..seed_index],
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let adopted_end = locate_adopted_segment_end(
store,
session_id,
@@ -923,6 +973,32 @@ fn read_history_turns_backward(
if before_offset > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&corrected_history_entries(&segment.seed_entries[..seed_index], &corrections),
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let mut reader = store
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
.map_err(map_history_store_error)?;
@@ -949,7 +1025,25 @@ fn read_history_turns_backward(
}
continue;
}
let entries = project_history_record(session_id, segment.segment_id, &record.entry);
if capture_tool_correction(&record.entry, &mut corrections) {
let LogEntry::ToolResultCorrected { entry, .. } = &record.entry else {
unreachable!()
};
let position = ToolCorrectionPosition {
entry_id: entry.metadata.entry_id.0.clone(),
segment_id: segment.segment_id,
start: record.start_offset,
end: record.end_offset,
};
if !correction_positions.contains(&position) {
correction_positions.push(position);
}
continue;
}
let entries = corrected_history_entries(
&project_history_record(session_id, segment.segment_id, &record.entry),
&corrections,
);
if collect_history_entries_backward(
&entries,
segment.segment_id,
@@ -967,7 +1061,7 @@ fn read_history_turns_backward(
if segment_index + 1 == lineage.len()
&& collect_history_entries_backward(
&segment.seed_entries,
&corrected_history_entries(&segment.seed_entries, &corrections),
segment.segment_id,
0,
None,
@@ -984,6 +1078,38 @@ fn read_history_turns_backward(
Ok(turns)
}
fn capture_tool_correction(
record: &LogEntry,
corrections: &mut HashMap<String, SessionSnapshotEntryData>,
) -> bool {
let LogEntry::ToolResultCorrected { entry, .. } = record else {
return false;
};
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
// We read backwards: the first correction seen is the latest one.
corrections
.entry(entry.metadata.entry_id.0.clone())
.or_insert(data);
}
true
}
fn corrected_history_entries(
entries: &[SessionSnapshotEntry],
corrections: &HashMap<String, SessionSnapshotEntryData>,
) -> Vec<SessionSnapshotEntry> {
entries
.iter()
.cloned()
.map(|mut entry| {
if let Some(data) = corrections.get(&entry.entry_id) {
entry.data = data.clone();
}
entry
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn collect_history_entries_backward(
entries: &[SessionSnapshotEntry],
@@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool {
fn encode_history_cursor(
cursor: &RetainedHistoryCursor,
) -> Result<String, RetainedHistoryReadError> {
serde_json::to_vec(cursor)
let encoded = serde_json::to_vec(cursor)
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
.map_err(|_| RetainedHistoryReadError::CorruptLog)
.map_err(|_| RetainedHistoryReadError::CorruptLog)?;
if encoded.len() > 4096 {
return Err(RetainedHistoryReadError::ResourceLimit);
}
Ok(encoded)
}
fn decode_history_cursor(cursor: &str) -> Result<RetainedHistoryCursor, RetainedHistoryReadError> {
@@ -1252,6 +1382,19 @@ pub(crate) fn project_session_snapshot_for_segment(
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::ToolResultCorrected { ts, entry } => {
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
if let Some(existing) = entries
.iter_mut()
.find(|existing| existing.entry_id == entry.metadata.entry_id.0)
{
// The original result keeps its place/time in the conversation.
existing.data = data;
} else {
entries.push(history_entry(entry, *ts, data));
}
}
}
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
@@ -1782,6 +1925,129 @@ mod tests {
.collect()
}
#[test]
fn image_correction_keeps_original_position_across_history_pages() {
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
let mut image = assistant_message(1, "image");
image.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"original image",
None,
agen::ToolResultDisposition::Success,
vec![agen::tool::Attachment::Image(
agen::tool::ImageAttachment::new("image/png", vec![1_u8, 2, 3]),
)],
)
.into();
let mut corrected = image.clone();
corrected.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Resize or crop and retry ViewImage",
None,
agen::ToolResultDisposition::Error,
Vec::new(),
)
.into();
let mut log = vec![segment_start(session_id, Vec::new(), None, None)];
append_turn(&mut log, 1);
log.push(LogEntry::AnnotatedToolResult {
ts: 15,
entry: image.clone(),
});
append_turn(&mut log, 2);
append_turn(&mut log, 3);
log.push(LogEntry::ToolResultCorrected {
ts: 35,
entry: corrected,
});
let (_root, aggregate_root) = persist_history_fixture(
"corrected-image",
session_id,
segment_id,
vec![(segment_id, log.clone())],
);
let first = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&first), ["user-3"]);
assert!(
!first.turns[0]
.entries
.iter()
.any(|entry| entry.entry_id == image.metadata.entry_id.0)
);
let second = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
first.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&second), ["user-2"]);
let third = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
second.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&third), ["user-1"]);
let entry = third.turns[0]
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap();
assert_eq!(entry.timestamp, 15);
assert!(
matches!(&entry.data, SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert_eq!(
*entry,
*project_session_snapshot(session_id, &log)
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap()
);
let restored = crate::collect_state(&log);
assert_eq!(
restored
.annotated_history
.iter()
.filter(|entry| entry.metadata.entry_id == image.metadata.entry_id)
.count(),
1
);
assert!(
matches!(&restored.annotated_history.iter().find(|entry| entry.metadata.entry_id == image.metadata.entry_id).unwrap().item, LoggedItem::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
// Cursor pointers are only hints: the referenced record is read under
// current lineage authority and must actually be a correction record.
let mut cursor = decode_history_cursor(first.next_cursor.as_deref().unwrap()).unwrap();
cursor.corrections[0].segment_id = crate::new_segment_id();
let forged = encode_history_cursor(&cursor).unwrap();
assert_eq!(
read_retained_session_history_page(
&aggregate_root,
"corrected-image",
Some(&forged),
Some(1),
RetainedHistoryReadLimits::default()
)
.unwrap_err(),
RetainedHistoryReadError::InvalidCursor
);
}
#[test]
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
let worker_name = "worker-history-12";
+13
View File
@@ -107,6 +107,9 @@ pub enum LogEntry {
/// Canonical tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// Append-only correction of a provider-rejected image result. Retains the
/// original stable identity; replay replaces rather than appends the item.
ToolResultCorrected { ts: u64, entry: LoggedHistoryEntry },
/// Canonical typed system event and model-visible metadata committed
/// together.
@@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::ToolResultCorrected { entry, .. } => {
if let Some(index) = state
.annotated_history
.iter()
.position(|existing| existing.metadata.entry_id == entry.metadata.entry_id)
{
state.annotated_history[index] = entry.clone();
state.history[index] = Item::from(entry.item.clone());
}
}
LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
+30
View File
@@ -175,6 +175,15 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh {
body: String,
behavior_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
@@ -201,6 +210,7 @@ impl SystemItem {
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(),
}
}
@@ -224,6 +234,7 @@ impl SystemItem {
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt",
}
}
@@ -280,6 +291,25 @@ mod tests {
));
}
#[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(),
behavior_revision: 7,
prompt_provenance: None,
};
let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!(
parsed,
SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
..
}
));
}
#[test]
fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification {
+1
View File
@@ -3042,6 +3042,7 @@ impl App {
| session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
+32 -5
View File
@@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient {
self
}
fn execute_with_permission(
fn execute_with_optional_timeout(
&self,
request: WorkspaceRequest,
permission: &'static str,
timeout: Option<Duration>,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
@@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient {
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
@@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
self.execute_with_optional_timeout(request, self.request_timeout)
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_optional_timeout(request, Some(timeout))
}
fn execute_server_operation(
@@ -1341,6 +1349,25 @@ mod tests {
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_honors_per_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
let client =
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
let error = client
.execute_with_timeout(
WorkspaceRequest::json(WorkspaceRequestMethod::Post, "/api/test", "{}"),
Duration::from_millis(20),
)
.unwrap_err();
assert_eq!(client.request_timeout, None);
let message = error.to_string();
assert!(message.contains("POST /api/test"), "{message}");
assert!(message.contains("timed out"), "{message}");
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_reports_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
+102 -24
View File
@@ -489,11 +489,29 @@ fn stage_pending_notifications<St: Store + Clone>(
count
}
/// The durable input append is the activation boundary. Clear live state in
/// the same poll, before invocation preparation can checkpoint it again; the
/// oneshot only publishes the already-completed transition to the Controller.
pub(crate) fn input_committed_hook<St: Store + Clone>(
pending: crate::worker::PendingSubmissionHandle<St>,
submission_id: String,
sender: oneshot::Sender<()>,
) -> impl FnOnce() {
move || {
pending.finish_activation(&submission_id);
let _ = sender.send(());
}
}
fn notification_coalesce_remaining<St: Store + Clone>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer,
delay: Duration,
can_schedule_run: bool,
) -> Option<Duration> {
if !can_schedule_run {
return None;
}
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
else {
return notify_buffer
@@ -1434,6 +1452,7 @@ where
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts(),
)?
{
let workspace_id = worker
@@ -1563,7 +1582,13 @@ where
),
);
}
if feature_config.manage_workdir.enabled {
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let mut workdir_wip_feature = None;
if feature_config.manage_workdir.enabled
|| (wip_mode
&& feature_config.workdir_catalog.enabled
&& worker.workspace_client_handle().is_available())
{
// Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs,
// repository paths, materializer handles, or cleanup sessions.
@@ -1579,7 +1604,7 @@ where
}
let shutdown_registry = spawned_registry.clone();
let reopen_registry = spawned_registry.clone();
feature_registry.add_module(
let module =
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client,
worker.workdir_sessions(),
@@ -1588,8 +1613,11 @@ where
Box::pin(async move { child_registry.shutdown_internal().await })
}),
Arc::new(move || reopen_registry.reopen_internal()),
),
);
);
if feature_config.manage_workdir.enabled {
feature_registry.add_module(module.clone());
}
workdir_wip_feature = Some(module);
}
if feature_config.workspace_worker_discovery.enabled {
let workspace_client = worker.workspace_client_handle();
@@ -1644,20 +1672,34 @@ where
}
let host_worker_observation_provider = worker.worker_observation_provider();
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let wip_permissions = worker.manifest().permissions.clone();
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry,
feature,
feature_config.workdir_catalog.enabled,
feature_config.manage_workdir.enabled,
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Workdir WIP projection: {error}"),
)
})?;
}
{
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
if feature_config.merge_request.any() && wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("merge-request")
let namespace_route = wip_mount_registry
.allocate_namespace("merge-request", "merge-requests")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Merge Request WIP route: {error}"),
format!("allocate Merge Request WIP namespace: {error}"),
)
})?;
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
@@ -1665,7 +1707,7 @@ where
workspace_client.clone(),
feature_config.merge_request,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1682,12 +1724,12 @@ where
intake: feature_config.ticket.intake,
workflow: feature_config.ticket.workflow,
};
let feature_route = wip_mount_registry
.allocate_feature_route("ticket")
let namespace_route = wip_mount_registry
.allocate_namespace("ticket", "tickets")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Ticket WIP route: {error}"),
format!("allocate Ticket WIP namespace: {error}"),
)
})?;
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
@@ -1695,7 +1737,7 @@ where
workspace_client.clone(),
ticket_access,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1716,19 +1758,19 @@ where
engine.register_tool(definition);
}
if wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("objective")
let namespace_route = wip_mount_registry
.allocate_namespace("objective", "objectives")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Objective WIP route: {error}"),
format!("allocate Objective WIP namespace: {error}"),
)
})?;
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
&mut wip_mount_registry,
workspace_client.clone(),
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -2140,9 +2182,11 @@ async fn controller_loop<C, St>(
submission.input,
vec![extension],
submission.provenance,
move || {
let _ = input_commit_tx.send(());
},
input_committed_hook(
pending_submissions.clone(),
committed_submission.submission_id.clone(),
input_commit_tx,
),
),
&mut method_rx,
&working_event_tx,
@@ -2199,10 +2243,6 @@ async fn controller_loop<C, St>(
.await
}
};
if notify_buffer.is_empty() {
pending_submissions.finish_notification_batch();
}
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
match prepare_pending_run(&pending_submissions, &notify_buffer, None, false) {
Ok(Some(next)) => {
@@ -2251,12 +2291,16 @@ async fn controller_loop<C, St>(
continue;
}
// An expired Notify deadline must not repeatedly schedule a run that
// cannot cross the invocation fence. Keep the accepted notification for
// a fresh input/recovery boundary instead of retrying business effects.
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
.then(|| {
notification_coalesce_remaining(
&pending_submissions,
&notify_buffer,
notification_coalesce_delay,
worker.can_schedule_notification_run(),
)
})
.flatten();
@@ -3930,6 +3974,40 @@ mod tests {
assert!(notify_buffer.has_notification_pending());
}
#[test]
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
let temp = TempDir::new().unwrap();
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
let buffer = NotifyBuffer::new();
pending
.accept_notification("held".into(), "held".into())
.unwrap();
let before = pending.persisted_entries_for_test().len();
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
for _ in 0..4 {
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
}
assert_eq!(pending.persisted_entries_for_test().len(), before);
assert_eq!(pending.snapshot().notification_count, 1);
// A busy-time staged notification is equally retained behind the fence.
stage_pending_notifications(&pending, &buffer);
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
assert!(buffer.has_notification_pending());
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
}
#[test]
fn restored_mixed_activations_preserve_global_fifo_order() {
let temp = TempDir::new().unwrap();
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`].
pub mod wip;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for ManageWorkdirFeature {
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
before_workdir_release: None,
after_workdir_attach: None,
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
session_router,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
backend
}
}
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
let backend = self.backend();
for (name, definition) in [
(
LIST_TOOL,
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
}
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
self.detach_expected(alias, None)
}
fn detach_expected(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let suffix = expected_connection_id
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
.unwrap_or_default();
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
path: format!(
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
),
body: None,
})?;
workdir_output(
@@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend {
)
}
async fn detach_managed(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let _mutation_guard = self.attachment_mutation_lock.lock().await;
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
if let Some(expected) = expected_connection_id {
// Under the shared local mutation lock, check lifetime before any
// local route exclusion or child cleanup. Backend DELETE still performs
// the authoritative conditional mutation under its lifecycle lock.
let workspace = encode_path_segment(self.workspace_id()?);
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(expected)
)))?;
let current = page
.get("items")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("connection_id")
.and_then(serde_json::Value::as_str)
== Some(expected)
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
})
});
if !current {
return Err(ToolError::StructuredConflict {
code: "workspace_http_409".into(),
message: "attachment lifetime changed before local cleanup".into(),
});
}
}
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.before_workdir_release
&& let Err(error) = before_release().await
{
self.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.detach_expected(alias, expected_connection_id) {
Ok(result) => result,
Err(error) => {
self.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
&self,
request: WorkspaceRequest,
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
result
}
WorkdirOperation::Detach => {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let input = parse_input::<WorkdirDetachInput>(input_json)?;
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.backend
.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.backend.before_workdir_release
&& let Err(error) = before_release().await
{
self.backend.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.backend.detach(alias) {
Ok(result) => result,
Err(error) => {
self.backend.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.backend
.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
self.backend.detach_managed(&input.alias, None).await
}
WorkdirOperation::Delete => self
.backend
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
@@ -747,6 +804,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
fn decode_response<T: for<'de> Deserialize<'de>>(
response: WorkspaceResponse,
) -> Result<T, ToolError> {
if matches!(response.status, 401 | 403 | 404 | 409) {
return Err(ToolError::StructuredConflict {
code: format!("workspace_http_{}", response.status),
message: "Workspace lifecycle authority rejected the request before execution".into(),
});
}
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Workdir API returned HTTP {}: {}",
File diff suppressed because it is too large Load Diff
+399 -68
View File
@@ -4,7 +4,9 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use arc_swap::ArcSwap;
@@ -23,11 +25,15 @@ use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
use crate::hook::{
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
WorkerRestored,
};
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
use crate::worker::{
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
#[derive(Clone, Debug)]
@@ -73,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
Decode(#[from] serde_json::Error),
#[error("workspace memory backend rejected operation: {0}")]
Backend(String),
#[error("workspace memory backend returned an invalid response: {0}")]
InvalidResponse(String),
}
impl dyn WorkspaceClient + '_ {
@@ -150,9 +158,24 @@ impl dyn WorkspaceClient + '_ {
}
}
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
execute_subjektiv_memory_operation_with(client, operation, |client, request| {
client.execute(request)
})
}
fn execute_subjektiv_memory_operation_with(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
execute: impl FnOnce(
&dyn WorkspaceClient,
WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, WorkspaceClientError>,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
let workspace_id =
client
@@ -163,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
client.kind()
),
})?;
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
))?;
let response = execute(
client,
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
),
)?;
let status = reqwest::StatusCode::from_u16(response.status)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
if !response.is_success() {
@@ -653,12 +679,17 @@ struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
}
const READY_EMPTY_RESIDENT_SURFACE: &str =
"The current ready resident memory surface is intentionally empty.";
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
fn resident_summary_contribution(
output: memory::backend::MemoryResidentSummaryOutput,
@@ -696,36 +727,133 @@ 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(),
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!(
"unexpected subject resident context response: {other:?}"
))),
}
}
fn render(
&self,
output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability {
Availability::Ready => "ready",
Availability::Ungenerated => "ungenerated",
Availability::Stale => "stale",
Availability::Failed => "failed",
Availability::Unavailable => "unavailable",
};
self.prompts
.load()
.subjektiv_resident_context(
&output.behavior_md,
availability,
output.memory_surface.content.as_deref(),
)
.map_err(|error| error.to_string())
}
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
}
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
match execute_subjektiv_memory_operation(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
)
.await
{
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
match resident_summary_contribution(output) {
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
}
contribution => contribution,
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok(rendered) => {
self.remember_behavior(&output);
SystemPromptContribution::Ready(rendered)
}
}
Ok(other) => {
tracing::debug!(?other, "unexpected subject resident Memory response");
SystemPromptContribution::Unavailable
}
Err(error) => {
tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready(
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
},
Err(error) => {
tracing::debug!(%error, "subject resident Memory unavailable");
SystemPromptContribution::Unavailable
tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready(
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
}
}
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
let output = self.fetch().await.map_err(|error| error.to_string())?;
let changed = self
.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
}
fn invalidate_resident_context_representation(&self) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = None;
}
}
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
/// shared host facility, while loading and rendering a post-restore refresh is
@@ -773,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
SUBJEKTIV_RESTORE_REFRESH_HOOK,
FeatureHookPoint::WorkerRestored,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
FeatureHookPoint::PreLlmRequest,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
FeatureHookPoint::BeforeSessionRewrite,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
@@ -784,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
prompts: Arc::clone(&self.prompts),
workspace_id: self.workspace_id.clone(),
},
)?;
context.hooks().add_pre_llm_request(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRefreshHook {
source: Arc::clone(&self.source),
},
)?;
context.hooks().add_before_session_rewrite(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRewriteHook {
source: Arc::clone(&self.source),
},
)
}
}
struct SubjektivBehaviorRewriteHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
async fn call(
&self,
_context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
self.source.invalidate_resident_context_representation();
Ok(BeforeSessionRewriteAction::Continue)
}
}
struct SubjektivBehaviorRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
let refresh = self
.source
.load_changed_resident_context()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
if let Some(refresh) = refresh {
let system_items = context.system_items().ok_or_else(|| {
HookError::new(
HookErrorCategory::Dependency,
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
}
Ok(HookPreRequestAction::Continue)
}
}
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -931,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
return Ok(None);
@@ -968,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
}
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
}
@@ -1100,6 +1293,10 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -1112,7 +1309,9 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
impl SubjectResidentClient {
@@ -1124,7 +1323,9 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
@@ -1133,7 +1334,9 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
}
@@ -1161,7 +1364,7 @@ mod tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
if !self.scope_allowed {
return Ok(WorkspaceResponse {
@@ -1169,19 +1372,33 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
},
),
)
.unwrap(),
})
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.timeouts.lock().unwrap().push(timeout);
self.execute(request)
}
}
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
@@ -1479,29 +1696,44 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
.unwrap()
.unwrap();
assert_eq!(
ready.load().await,
SystemPromptContribution::Ready("current subject surface".into())
);
let ready = ordinary_subjektiv_resident_summary_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");
};
assert!(loaded.contains("Be deliberate."));
assert!(loaded.contains("current subject surface"));
assert_eq!(
ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
);
assert_eq!(
ready_client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT]
);
let ready_empty_client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
.unwrap()
.unwrap();
assert_eq!(
ready_empty.load().await,
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
);
let ready_empty = ordinary_subjektiv_resident_summary_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");
};
assert!(ready_empty.contains("Be deliberate."));
assert!(ready_empty.contains("intentionally empty"));
for availability in [
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1514,22 +1746,107 @@ permission = "write"
availability,
Some("must not be injected"),
)),
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(loaded) = source.load().await else {
panic!("Subject behavior must survive unavailable surfaces");
};
assert!(loaded.contains("Be deliberate."));
assert!(!loaded.contains("must not be injected"));
}
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let initial = source.load().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
let replay_after_rewrite = source
.load_changed_resident_context()
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert!(
replay_after_rewrite
.body
.contains("No user-managed behavior is set")
);
// Initial load and every live update/clear/replay probe retain the
// target-side bounded resident request contract.
assert_eq!(
client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
);
}
#[tokio::test]
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())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
};
assert!(failure.contains("could not fetch"));
assert_eq!(
client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1557,7 +1874,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1569,22 +1886,30 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
.unwrap()
.is_none()
ordinary_subjektiv_resident_summary_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()).is_err());
assert!(
ordinary_subjektiv_resident_summary_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())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
));
@@ -1598,9 +1923,10 @@ 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())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
source,
Arc::new(ArcSwap::from(
@@ -1611,8 +1937,13 @@ permission = "write"
let descriptor = feature.descriptor();
assert!(descriptor.tools.is_empty());
assert_eq!(descriptor.hooks.len(), 1);
assert_eq!(descriptor.hooks.len(), 3);
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
assert_eq!(
descriptor.hooks[2].point,
FeatureHookPoint::BeforeSessionRewrite
);
}
#[test]
@@ -5,9 +5,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
let (meta, tool) = definition(client.clone(), kind)();
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!("Merge Request tool schema cannot be retained: {error}"),
}
})?;
@@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip(
client: Arc<dyn WorkspaceClient>,
config: MergeRequestFeatureConfig,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("merge-requests")?;
let collection_route = namespace_route.root().to_string();
let tools = native_merge_request_tools(client, config)?;
let claimed_tools = tools
.iter()
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!(
"Merge Request tool `{}` schema has no properties",
tool.name
@@ -1474,7 +1474,9 @@ mod tests {
#[test]
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("merge-request").unwrap();
let namespace_route = registry
.allocate_namespace("merge-request", "merge-requests")
.unwrap();
mount_workspace_http_merge_request_wip(
&mut registry,
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
@@ -1484,17 +1486,14 @@ mod tests {
..Default::default()
},
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/merge-request/merge-requests"]
);
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
};
assert!(
@@ -1645,7 +1644,7 @@ mod tests {
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions),
};
let first = resolver.resolve("MR-1").unwrap().object.validator;
+21 -24
View File
@@ -21,9 +21,9 @@ use wip_protocol::{
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
registry: &mut WipMountRegistry,
client: Arc<dyn WorkspaceClient>,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("objectives")?;
let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor();
@@ -1775,7 +1775,9 @@ mod tests {
}));
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("objective").unwrap();
let namespace_route = registry
.allocate_namespace("objective", "objectives")
.unwrap();
mount_workspace_http_objective_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -1783,18 +1785,16 @@ mod tests {
"http://backend",
)),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/objective/objectives"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
assert_eq!(namespace_route.root(), "/objectives");
assert!(
registry
.allocate_namespace("objective", "../tickets")
.is_err()
);
assert_eq!(
feature_route.child("objectives").unwrap(),
"/features/objective/objectives"
);
assert!(feature_route.child("../tickets").is_err());
}
#[test]
@@ -1805,7 +1805,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let initial = resolver.resolve("O-3").unwrap();
@@ -1971,7 +1971,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend,
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = collection
@@ -2013,7 +2013,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let context = || WipCallContext {
@@ -2170,7 +2170,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let output = match handler
@@ -2191,10 +2191,7 @@ mod tests {
let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["next_cursor"], "next-page");
assert_eq!(output["has_more"], true);
assert_eq!(
output["objectives"][0]["path"],
"/features/objective/objectives/O-3"
);
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
assert!(!output.to_string().contains("00001OBJECTIVE"));
assert!(!output.to_string().contains("00001TICKET"));
@@ -2243,7 +2240,7 @@ mod tests {
let resolver = ObjectiveItemResolver {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let before = resolver.resolve("O-3").unwrap().object.validator;
+18 -17
View File
@@ -34,9 +34,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
let (meta, tool) = definition();
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!(
"enabled Ticket tool `{}` has no native projection",
meta.name
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
})?;
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool schema cannot be retained: {error}"),
}
})?;
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("tickets")?;
let collection_route = namespace_route.root().to_string();
let tools = native_ticket_tools(client, access)?;
let claimed_tools = tools
.iter()
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool `{}` schema has no properties", tool.name),
})?;
let required = tool
@@ -2103,7 +2103,7 @@ mod tests {
#[test]
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("ticket").unwrap();
let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap();
mount_workspace_http_ticket_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -2112,20 +2112,21 @@ mod tests {
)),
TicketFeatureAccess::review(),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/ticket/tickets"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
assert!(
registry
.allocate_namespace("ticket", "../objectives")
.is_err()
);
assert!(feature_route.child("../objectives").is_err());
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions,
};
assert!(resolver.resolve("T-42").is_some());
@@ -2189,7 +2190,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
@@ -2243,7 +2244,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
+14
View File
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
.expect("system-item append queue poisoned")
.push(item);
}
/// Queue a revisioned Subject behavior refresh. The caller receives this
/// narrow operation only at a pre-request boundary, and the host commits it
/// before exposing it to the model.
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(SystemItem::SubjectBehaviorRefresh {
body: body.into(),
behavior_revision,
prompt_provenance: None,
});
}
}
/// Host-created narrow append authority exposed only at a restore lifecycle
+221
View File
@@ -0,0 +1,221 @@
//! Recovery tests use the normal Worker execution and append-log commit gate.
use super::*;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use async_trait::async_trait;
#[derive(Clone, Default)]
struct RejectImages {
requests: Arc<Mutex<Vec<Request>>>,
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
}
#[async_trait]
impl LlmClient for RejectImages {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let has_image = request.items.iter().any(
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty()),
);
self.requests.lock().unwrap().push(request);
if has_image {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
retry_after: None,
});
}
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
cancel.try_send(()).unwrap();
return Ok(Box::pin(futures::stream::pending()));
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Resize the source image")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn image_worker() -> (
tempfile::TempDir,
Worker<RejectImages, session_store::FsStore>,
RejectImages,
) {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("session")).unwrap();
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "image-recovery-test"
[model]
scheme = "anthropic"
model_id = "test"
[engine]
[scope]
allow = []
"#,
)
.unwrap();
let client = RejectImages::default();
let mut worker = Worker::new(
manifest,
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
store,
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
Scope::writable(dir.path()).unwrap(),
)
.await
.unwrap();
worker.disable_manifest_lifecycle_features();
worker.set_history_for_test(vec![
Item::user_message("inspect this image"),
Item::tool_call("image-call", "ViewImage", "{}"),
Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Attached image",
None,
agen::ToolResultDisposition::Success,
vec![Attachment::Image(ImageAttachment::new(
"image/png",
vec![1_u8, 2, 3],
))],
),
]);
worker.wire_history_persistence();
(dir, worker, client)
}
fn rejected(item: &Item) -> bool {
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
}
#[tokio::test]
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
let (_dir, mut worker, client) = image_worker().await;
assert_eq!(
worker.run_text("continue").await.unwrap(),
WorkerRunResult::Finished
);
assert_eq!(client.requests.lock().unwrap().len(), 2);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
let correction = raw
.iter()
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
.unwrap()
.clone();
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
assert_eq!(
session_store::collect_state(&raw)
.history
.iter()
.filter(|item| rejected(item))
.count(),
1
);
let LogEntry::ToolResultCorrected {
entry: corrected, ..
} = &correction
else {
unreachable!()
};
let original = match &raw[0] {
LogEntry::AnnotatedSegmentStart { history, .. } => history
.iter()
.find(|entry| entry.metadata.entry_id == corrected.metadata.entry_id)
.unwrap(),
_ => panic!("missing seed"),
};
assert!(
matches!(&original.item, session_store::LoggedItem::ToolResult { attachments, is_error: false, .. } if attachments.len() == 1)
);
assert_eq!(
restored
.iter()
.find(|entry| rejected(&entry.item))
.unwrap()
.annotation,
original.metadata
);
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&raw);
let projected = snapshot
.entries
.iter()
.find(|entry| entry.entry_id == corrected.metadata.entry_id.0)
.unwrap();
assert!(
matches!(&projected.data, protocol::SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert!(matches!(
crate::ipc::protocol_session::live_log_entry_event(correction),
Some(Event::SessionEntryCommitted { .. })
));
// Fork/compact seed writers serialize the effective annotated history.
let seed = LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: worker.session_id(),
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: restored.iter().map(to_logged_history_entry).collect(),
forked_from: None,
compacted_from: None,
};
let restored_again =
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
assert_eq!(
restored_again
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
let mut history = History::from_entries(restored_again);
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
Ok(SessionHistoryMetadata::legacy_unknown())
})
.await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(client.requests.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
let (_dir, mut worker, client) = image_worker().await;
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
let result = worker.run_text("continue").await.unwrap();
assert_eq!(result, WorkerRunResult::Cancelled);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
assert!(
raw.iter()
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
);
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
}
+217 -22
View File
@@ -38,7 +38,9 @@ use crate::hook::{
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter;
use crate::worker::{
DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource,
};
use agen::HistoryEntry;
use agen::token_counter::total_tokens;
@@ -109,6 +111,8 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
notification_committer: Option<Arc<dyn DurableNotificationCommitter>>,
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
@@ -163,12 +167,22 @@ impl WorkerInterceptor {
prompts,
prompt_workspace_id: None,
log_writer,
notification_committer: None,
resident_context_source: None,
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
}
pub(crate) fn with_notification_committer(
mut self,
committer: Option<Arc<dyn DurableNotificationCommitter>>,
) -> Self {
self.notification_committer = committer;
self
}
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
self.usage_tracker = Some(usage_tracker);
self
@@ -179,11 +193,31 @@ impl WorkerInterceptor {
self
}
pub(crate) fn with_resident_context_source(
mut self,
source: Option<Arc<dyn SystemPromptContributionSource>>,
) -> Self {
self.resident_context_source = source;
self
}
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
self.prompt_workspace_id = workspace_id;
self
}
fn confirm_resident_context_item(&self, item: &SystemItem) {
let Some(source) = self.resident_context_source.as_ref() else {
return;
};
if let SystemItem::SubjectBehaviorRefresh {
behavior_revision, ..
} = item
{
source.confirm_resident_context_revision(*behavior_revision);
}
}
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@@ -211,6 +245,7 @@ impl WorkerInterceptor {
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
self.confirm_resident_context_item(item);
}
Ok(())
}
@@ -350,6 +385,11 @@ impl WorkerInterceptor {
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.task_reminder"));
}
SystemItem::SubjectBehaviorRefresh {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
}
SystemItem::Interrupt {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
@@ -452,19 +492,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
projection_digest: projection.catalog_digest.clone(),
logical_name: "internal.notify_wrapper".to_string(),
};
let mut system_items: Vec<(
SystemItem,
Vec<session_store::SessionExtension>,
Option<session_store::LoggedSessionHistoryOrigin>,
)> = Vec::with_capacity(drained.len());
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
let mut system_items = Vec::with_capacity(drained.len());
for entry in &drained {
let system_item = match build_system_item_with_provenance(
entry,
&prompts,
Some(provenance.clone()),
) {
Ok(system_item) => system_item,
match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) {
Ok(item) => system_items.push(item),
Err(error) => {
self.pending_notifies.requeue_front(drained);
return Err(InterceptorError::new(
@@ -472,16 +503,50 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
format!("failed to render notify_wrapper: {error}"),
));
}
};
items.push(system_item.to_history_item());
system_items.push((system_item, entry.extensions(), entry.history_provenance()));
}
}
if let Err(error) = self.commit_system_items_with_extensions(&system_items) {
self.pending_notifies.requeue_front(drained);
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("session persistence failed: {error}"),
));
let mut items = Vec::with_capacity(drained.len());
let mut remaining = drained.into_iter();
for item in system_items {
let entry = remaining.next().expect("rendered notification must exist");
let committed = if let Some(identity) = entry.notification_commit() {
match self.notification_committer.as_ref() {
Some(committer) => {
committer.commit_notification(identity, item, entry.history_provenance())
}
None => Err(session_store::StoreError::Io(std::io::Error::other(
"durable notification commit authority is missing",
))),
}
} else {
self.commit_system_items_with_extensions(&[(
item.clone(),
Vec::new(),
entry.history_provenance(),
)])
.map(|()| {
items.push(item.to_history_item());
None
})
};
match committed {
Ok(Some(history)) => {
items.push(history.item.clone());
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(history);
}
Ok(None) => {}
Err(error) => {
self.pending_notifies
.requeue_front(std::iter::once(entry).chain(remaining).collect());
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("session persistence failed: {error}"),
));
}
}
}
Ok(items)
}
@@ -781,6 +846,7 @@ mod tests {
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
};
use crate::session_history::{WorkerHistoryProvenance, history_entry};
use crate::worker::SystemPromptContribution;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
@@ -877,6 +943,53 @@ mod tests {
}
}
struct AppendingSubjectBehaviorHook;
#[async_trait]
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
async fn call(
&self,
input: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
input
.system_items()
.expect("test interceptor has durable append authority")
.append_subject_behavior_refresh("current Subject context", 7);
Ok(HookPreRequestAction::Continue)
}
}
struct ConfirmRecordingResidentSource {
confirmed: Arc<Mutex<Vec<u64>>>,
}
#[async_trait]
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
async fn load(&self) -> SystemPromptContribution {
SystemPromptContribution::Unavailable
}
fn confirm_resident_context_revision(&self, revision: u64) {
self.confirmed.lock().unwrap().push(revision);
}
}
struct FailingSystemItemCommitter;
impl SystemItemCommitter for FailingSystemItemCommitter {
fn commit_log_entry(
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) {
return Err(session_store::StoreError::Io(std::io::Error::other(
"synthetic system-item failure",
)));
}
Ok(())
}
}
fn task_tool_call_info(
name: &str,
input: serde_json::Value,
@@ -1364,6 +1477,88 @@ mod tests {
}
}
#[tokio::test]
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let committed = Arc::new(Mutex::new(Vec::new()));
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
assert_eq!(*confirmed.lock().unwrap(), [7]);
assert!(matches!(
committed.lock().unwrap().as_slice(),
[SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
prompt_provenance: Some(_),
..
}]
));
}
#[tokio::test]
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(FailingSystemItemCommitter)),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(
action,
PreRequestAction::Cancel(reason) if reason.contains("session persistence failed")
));
assert!(confirmed.lock().unwrap().is_empty());
}
#[tokio::test]
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
let saw_handle = Arc::new(AtomicBool::new(false));
+13 -9
View File
@@ -24,8 +24,9 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::worker::NotificationCommitIdentity;
use protocol::WorkerEvent;
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
use session_store::{LoggedSessionHistoryOrigin, SystemItem};
use crate::prompt::catalog::{CatalogError, PromptCatalog};
@@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
pub enum PendingNotify {
Notify {
message: String,
extensions: Vec<SessionExtension>,
notification_commit: Option<NotificationCommitIdentity>,
history_provenance: Option<LoggedSessionHistoryOrigin>,
},
WorkerEvent {
@@ -48,10 +49,13 @@ pub enum PendingNotify {
}
impl PendingNotify {
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> {
match self {
PendingNotify::Notify { extensions, .. } => extensions.clone(),
PendingNotify::WorkerEvent { .. } => Vec::new(),
PendingNotify::Notify {
notification_commit,
..
} => notification_commit.as_ref(),
PendingNotify::WorkerEvent { .. } => None,
}
}
@@ -85,7 +89,7 @@ impl NotifyBuffer {
pub fn push_notify(&self, message: String) {
self.push_entry(PendingNotify::Notify {
message,
extensions: Vec::new(),
notification_commit: None,
history_provenance: None,
});
}
@@ -94,11 +98,11 @@ impl NotifyBuffer {
&self,
message: String,
history_provenance: LoggedSessionHistoryOrigin,
extension: SessionExtension,
identity: NotificationCommitIdentity,
) {
self.push_entry(PendingNotify::Notify {
message,
extensions: vec![extension],
notification_commit: Some(identity),
history_provenance: Some(history_provenance),
});
}
@@ -233,7 +237,7 @@ mod tests {
fn build_system_item_for_notify_carries_wrapper_body() {
let entry = PendingNotify::Notify {
message: "hello".into(),
extensions: Vec::new(),
notification_commit: None,
history_provenance: None,
};
let catalog = PromptCatalog::builtins_only().unwrap();
@@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
}
entry @ (LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
+18
View File
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
AgentsMdSection,
ResidentMemorySummarySection,
ResidentMemoryRestoreSection,
SubjektivResidentContext,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::AgentsMdSection => "internal.agents_md_section",
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
}
@@ -150,6 +152,7 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -518,6 +521,21 @@ impl PromptCatalog {
},
)
}
pub fn subjektiv_resident_context(
&self,
behavior_md: &str,
surface_availability: &str,
surface_body: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivResidentContext,
minijinja::context! {
behavior_md => behavior_md,
surface_availability => surface_availability,
surface_body => surface_body.unwrap_or_default(),
},
)
}
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection,
+1
View File
@@ -121,6 +121,7 @@ impl SegmentLogSink {
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
| LogEntry::RunYielded { .. }
+11
View File
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::ToolResultCorrected { entry, .. } => {
let target = history
.iter_mut()
.find(|existing| existing.annotation.entry_id == entry.metadata.entry_id)
.ok_or_else(|| "tool result correction target is missing".to_string())?;
if !matches!((&target.item, &entry.item), (Item::ToolResult { call_id, .. }, session_store::LoggedItem::ToolResult { call_id: replacement, .. }) if call_id == replacement)
{
return Err("tool result correction changed call identity".to_string());
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
+2856 -147
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+658 -30
View File
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 77;
const LATEST_SCHEMA_VERSION: i64 = 78;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -77,6 +77,7 @@ const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
"preserve explicit observe grants for committed Worker Session archives";
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
"durable terminal Backend Job Worker cleanup";
const WORKDIR_CONNECTION_ID_MIGRATION_NAME: &str = "durable Workdir attachment connection identity";
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
@@ -259,6 +260,11 @@ const MIGRATIONS: &[Migration] = &[
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
},
Migration {
version: 78,
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME,
apply: migrate_workdir_connection_id_v77_to_v78,
},
];
#[derive(Clone, Copy)]
@@ -945,6 +951,23 @@ pub struct WorkdirRegistryRecord {
pub updated_at: String,
}
/// Projection inputs captured together with the catalog revision in one SQLite snapshot.
#[derive(Debug, Clone)]
pub struct WorkdirCatalogEntry {
pub record: WorkdirRegistryRecord,
pub repository_key: Option<String>,
/// None for Repository records and unsupported legacy External grants.
pub external_grant_permissions: Option<workdir::workspace::WorkdirPermissionSummary>,
pub occupied_by: Option<workdir::workspace::WorkingDirectoryOccupancy>,
}
#[derive(Debug, Clone)]
pub struct WorkdirCatalogPage {
pub entries: Vec<WorkdirCatalogEntry>,
pub next_cursor: Option<String>,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalWorkdirGrantRecord {
pub grant_id: String,
@@ -963,6 +986,8 @@ pub struct ExternalWorkdirGrantRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerWorkdirLinkRecord {
/// Assigned by the ledger on attach; preserved only by explicit compensation.
pub connection_id: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub workdir_id: String,
@@ -1888,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
workspace_id: &str,
limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>>;
fn workdir_catalog_page(
&self,
workspace_id: &str,
limit: u32,
cursor: Option<&str>,
) -> Result<WorkdirCatalogPage>;
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
fn create_external_workdir_grant(
@@ -1983,6 +2014,31 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
expected_workdir_id: Option<&str>,
unlinked_at: &str,
) -> Result<Option<WorkerWorkdirLinkRecord>>;
fn detach_worker_workdir_connection(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
alias: &str,
expected_connection_id: &str,
unlinked_at: &str,
) -> Result<WorkerWorkdirLinkRecord>;
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
fn list_worker_workdir_links_page(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_worker_workdir_links_page_with_revision(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<(Vec<WorkerWorkdirLinkRecord>, String)>;
fn worker_workdir_link_history_exists(
&self,
workspace_id: &str,
@@ -8876,6 +8932,72 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn workdir_catalog_page(
&self,
workspace_id: &str,
limit: u32,
cursor: Option<&str>,
) -> Result<WorkdirCatalogPage> {
if limit == 0 || limit > 100 {
return Err(Error::InvalidInput(
"catalog page limit must be 1..=100".to_string(),
));
}
if cursor.is_some_and(|id| {
id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control)
}) {
return Err(Error::InvalidInput(
"catalog cursor must contain 1..=128 bytes without control characters".to_string(),
));
}
self.with_conn(|conn| {
// Also fence writers using other SQLite connections, not only this mutex.
let tx = conn.unchecked_transaction()?;
let sql = workdir_catalog_select_sql();
let mut hasher = Sha256::new();
update_public_collection_digest(&mut hasher, &("workdir-catalog:v1", workspace_id))?;
{
let mut stmt = tx.prepare(&format!("{sql} WHERE wr.workspace_id = ?1 ORDER BY wr.workdir_id"))?;
let mut rows = stmt.query(params![workspace_id])?;
while let Some(row) = rows.next()? {
let (entry, connection_id) = read_workdir_catalog_entry(row)?;
let record = &entry.record;
// Safe projection fields and timestamps only: no paths, provider identity,
// credentials, Repository UUIDs, or raw internal records.
update_public_collection_digest(&mut hasher, &(
&record.workdir_id, &record.display_name, &entry.repository_key,
record.source.external_grant_id(), &entry.external_grant_permissions,
(&record.creation_selector, &record.creation_ref, &record.creation_tree),
(&record.current_selector, &record.current_ref, &record.current_tree),
record.observed_at_epoch_seconds,
(&record.materialization_status, &record.cleanliness),
(&record.created_at, &record.updated_at),
&entry.occupied_by, connection_id,
))?;
}
}
let mut stmt = tx.prepare(&format!(
"{sql} WHERE wr.workspace_id = ?1 AND (?2 IS NULL OR wr.workdir_id > ?2) ORDER BY wr.workdir_id ASC LIMIT ?3"
))?;
let mut rows = stmt.query(params![workspace_id, cursor, limit + 1])?;
let mut entries = Vec::new();
while let Some(row) = rows.next()? {
entries.push(read_workdir_catalog_entry(row)?.0);
}
let next_cursor = if entries.len() > limit as usize {
entries.truncate(limit as usize);
entries.last().map(|entry| entry.record.workdir_id.clone())
} else {
None
};
Ok(WorkdirCatalogPage {
entries,
next_cursor,
revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
})
})
}
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
@@ -9349,6 +9471,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"reserved attachment finalization requires an active attachment".to_string(),
));
}
let mut record = record.clone();
record.connection_id = uuid::Uuid::now_v7().to_string();
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
@@ -9370,11 +9494,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
capabilities = excluded.capabilities,
linked_at = excluded.linked_at,
connection_id = excluded.connection_id,
unlinked_at = NULL"#,
params![
record.workspace_id,
@@ -9384,6 +9509,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.alias,
encode_workdir_link_capabilities(record.capabilities)?,
record.linked_at,
record.connection_id,
],
)
.map_err(|error| {
@@ -9421,6 +9547,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"a new attachment cannot already be unlinked".to_string(),
));
}
let mut record = record.clone();
record.connection_id = uuid::Uuid::now_v7().to_string();
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
@@ -9456,7 +9584,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let active_for_alias = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND unlinked_at IS NULL"#,
@@ -9499,7 +9627,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let active_for_workdir = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
params![record.workspace_id, record.workdir_id],
@@ -9514,11 +9642,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let write = tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
capabilities = excluded.capabilities,
linked_at = excluded.linked_at,
connection_id = excluded.connection_id,
unlinked_at = NULL"#,
params![
record.workspace_id,
@@ -9528,6 +9657,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.alias,
encode_workdir_link_capabilities(record.capabilities)?,
record.linked_at,
record.connection_id,
],
);
if let Err(error) = write {
@@ -9556,7 +9686,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
rusqlite::TransactionBehavior::Immediate,
)?;
let mut stmt = tx.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL"#,
@@ -9571,7 +9701,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
let mut persisted_identity = persisted
.iter()
.map(|link| (link.alias.as_str(), link.workdir_id.as_str()))
.map(|link| (link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
.collect::<Vec<_>>();
persisted_identity.sort_unstable();
let mut requested_identity = active_links
@@ -9592,7 +9722,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
link.alias
))
})?;
Ok((link.alias.as_str(), link.workdir_id.as_str()))
Ok((link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
})
.collect::<Result<Vec<_>>>()?;
requested_identity.sort_unstable();
@@ -9644,7 +9774,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?;
let active = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#,
@@ -9683,6 +9813,202 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn detach_worker_workdir_connection(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
alias: &str,
expected_connection_id: &str,
unlinked_at: &str,
) -> Result<WorkerWorkdirLinkRecord> {
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
rusqlite::TransactionBehavior::Immediate,
)?;
let active = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
alias,
expected_connection_id
],
read_worker_workdir_link_record,
)
.optional()?
.ok_or_else(|| {
Error::WorkdirAttachmentConflict(format!(
"Workdir attachment `{alias}` connection changed during detach"
))
})?;
let changed = tx.execute(
r#"UPDATE worker_workdir_links SET unlinked_at = ?6
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
alias,
expected_connection_id,
unlinked_at
],
)?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir attachment `{alias}` connection changed during detach"
)));
}
tx.commit()?;
Ok(WorkerWorkdirLinkRecord {
unlinked_at: Some(unlinked_at.to_string()),
..active
})
})
}
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> {
self.with_conn(|conn| {
// Compensation reactivates only the same lifetime, never an intervening attach.
let changed = conn.execute(
r#"UPDATE worker_workdir_links SET unlinked_at = NULL
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND workdir_id = ?4 AND alias = ?5 AND connection_id = ?6
AND unlinked_at IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM worker_workdir_attachment_reservations
WHERE workspace_id = ?1 AND workdir_id = ?4
)"#,
params![record.workspace_id, record.worker.runtime_id, record.worker.worker_id,
record.workdir_id, record.alias, record.connection_id],
).map_err(|error| {
if matches!(error, rusqlite::Error::SqliteFailure(ref code, _) if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE) {
Error::WorkdirAttachmentConflict("Workdir acquired another active attachment during compensation".to_string())
} else {
error.into()
}
})?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(
"Workdir attachment connection changed during compensation".to_string(),
));
}
Ok(())
})
}
fn list_worker_workdir_links_page(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.list_worker_workdir_links_page_with_revision(
workspace_id,
worker,
limit,
offset,
connection_id,
)
.map(|(links, _)| links)
}
fn list_worker_workdir_links_page_with_revision(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<(Vec<WorkerWorkdirLinkRecord>, String)> {
if limit == 0 || limit > 101 {
return Err(Error::InvalidInput(
"attachment page limit must be 1..=101".to_string(),
));
}
if connection_id.is_some_and(|id| {
id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control)
}) {
return Err(Error::InvalidInput(
"attachment connection_id must contain 1..=128 bytes without control characters"
.to_string(),
));
}
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let mut hasher = Sha256::new();
update_public_collection_digest(
&mut hasher,
&(
"workdir-connections:v1",
workspace_id,
&worker.runtime_id,
&worker.worker_id,
),
)?;
{
let mut stmt = tx.prepare(
"SELECT alias, connection_id, workdir_id, capabilities FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL ORDER BY alias"
)?;
let mut rows =
stmt.query(params![workspace_id, worker.runtime_id, worker.worker_id])?;
while let Some(row) = rows.next()? {
let alias: String = row.get(0)?;
let id: String = row.get(1)?;
let workdir: String = row.get(2)?;
let capabilities =
decode_workdir_link_capabilities(&row.get::<_, String>(3)?, 3).map_err(
|_| Error::Store("invalid public connection capabilities".to_string()),
)?;
update_public_collection_digest(
&mut hasher,
&(alias, id, workdir, capabilities),
)?;
}
}
let mut stmt = tx.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL
AND (?6 IS NULL OR connection_id = ?6)
ORDER BY alias LIMIT ?4 OFFSET ?5"#,
)?;
let rows = stmt.query_map(
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
limit,
offset,
connection_id
],
read_worker_workdir_link_record,
)?;
let links = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok((
links,
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
))
})
}
fn worker_workdir_link_history_exists(
&self,
workspace_id: &str,
@@ -9708,7 +10034,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -9729,7 +10055,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -9750,7 +10076,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Option<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2
ORDER BY linked_at DESC, rowid DESC
@@ -10497,6 +10823,7 @@ fn read_worker_workdir_link_record(
capabilities,
linked_at: row.get(6)?,
unlinked_at: row.get(7)?,
connection_id: row.get(8)?,
})
}
@@ -11442,6 +11769,96 @@ fn require_expected_ticket_assignment(
)))
}
// Length-framed canonical JSON prevents ambiguous concatenations and never hashes raw ledgers.
fn update_public_collection_digest(hasher: &mut Sha256, value: &impl Serialize) -> Result<()> {
let bytes = serde_json::to_vec(value)
.map_err(|_| Error::Store("unable to encode public collection metadata".to_string()))?;
hasher.update((bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
Ok(())
}
fn workdir_catalog_select_sql() -> String {
format!(
"SELECT wr.*, r.repository_key, g.permissions,
l.runtime_id, l.worker_id, w.display_name, l.linked_at, l.connection_id
FROM ({}) wr
LEFT JOIN repositories r ON r.workspace_id = wr.workspace_id AND r.repository_id = wr.repository_id
LEFT JOIN external_workdir_grants g ON g.workspace_id = wr.workspace_id AND g.grant_id = wr.external_grant_id
LEFT JOIN worker_workdir_links l ON l.rowid = (
SELECT rowid FROM worker_workdir_links
WHERE workspace_id = wr.workspace_id AND workdir_id = wr.workdir_id AND unlinked_at IS NULL
ORDER BY linked_at DESC, rowid DESC LIMIT 1
)
LEFT JOIN worker_registry w ON w.workspace_id = l.workspace_id AND w.runtime_id = l.runtime_id AND w.worker_id = l.worker_id",
workdir_registry_select_sql("")
)
}
fn read_workdir_catalog_entry(
row: &rusqlite::Row<'_>,
) -> Result<(WorkdirCatalogEntry, Option<String>)> {
let record = read_workdir_registry_record(row)?;
let repository_key: Option<String> = row.get(18)?;
let external_grant_permissions = match &record.source {
WorkdirRegistrySource::Repository { .. } => {
if repository_key.is_none() {
return Err(Error::Store(
"Workdir catalog Repository is unavailable".to_string(),
));
}
None
}
WorkdirRegistrySource::ExternalGrant { .. } => {
let permissions: Option<String> = row.get(19)?;
match permissions.as_deref() {
Some("read_only") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: false,
command: false,
}),
Some("read_write") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: true,
command: false,
}),
Some("read_write_command") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: true,
command: true,
}),
Some("command_only" | "read_command") => None,
_ => {
return Err(Error::Store(
"Workdir catalog External grant permissions are unavailable".to_string(),
));
}
}
}
};
let runtime_id: Option<String> = row.get(20)?;
let occupied_by = match runtime_id {
Some(runtime_id) => Some(workdir::workspace::WorkingDirectoryOccupancy {
runtime_id,
worker_id: row.get(21)?,
display_name: row.get::<_, Option<String>>(22)?.ok_or_else(|| {
Error::Store("Workdir catalog occupant is unavailable".to_string())
})?,
linked_at: row.get(23)?,
}),
None => None,
};
Ok((
WorkdirCatalogEntry {
record,
repository_key,
external_grant_permissions,
occupied_by,
},
row.get(24)?,
))
}
fn workdir_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \
@@ -14486,6 +14903,27 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()
Ok(())
}
fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> {
use rusqlite::TransactionBehavior;
let current = current_schema_version(conn)?;
if current != 77 {
return Err(Error::Store(format!(
"expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
"ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));",
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(include_str!("latest_schema.sql"))?;
@@ -14546,6 +14984,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
);
CREATE INDEX typed_ticket_targets_workspace_repository
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
ALTER TABLE worker_workdir_links
ADD COLUMN capabilities TEXT NOT NULL
CHECK (capabilities IN (
@@ -15610,6 +16049,46 @@ mod tests {
Ok(())
}
#[test]
fn workdir_connection_migration_backfills_distinct_durable_lifetimes() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE __yoi_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO __yoi_schema_migrations VALUES (77, 'workspace schema baseline');
CREATE TABLE worker_workdir_links (alias TEXT, linked_at TEXT, unlinked_at TEXT);
INSERT INTO worker_workdir_links VALUES ('checkout', 'same-time', NULL), ('docs', 'same-time', 'removed');",
).unwrap();
migrate_workdir_connection_id_v77_to_v78(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 78);
let rows = || {
let mut stmt = conn.prepare("SELECT alias, linked_at, unlinked_at, connection_id FROM worker_workdir_links ORDER BY alias").unwrap();
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
let migrated = rows();
assert_eq!(migrated[0].0, "checkout");
assert_eq!(migrated[0].1, "same-time");
assert_eq!(migrated[0].2, None);
assert_eq!(migrated[1].2.as_deref(), Some("removed"));
assert!(!migrated[0].3.is_empty());
assert_ne!(migrated[0].3, migrated[1].3);
assert!(migrate_workdir_connection_id_v77_to_v78(&conn).is_err());
assert_eq!(
rows(),
migrated,
"a retry cannot replace an already durable connection ID"
);
}
#[test]
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
@@ -17052,6 +17531,10 @@ mod tests {
version: 77,
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 78,
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(),
},
]
);
@@ -17139,6 +17622,7 @@ mod tests {
77,
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
),
(78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -17490,7 +17974,7 @@ mod tests {
.collect::<Vec<_>>(),
vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
73, 74, 75, 76, 77
73, 74, 75, 76, 77, 78
]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -17499,7 +17983,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
@@ -20855,6 +21339,7 @@ INSERT INTO worker_registry (
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
let link = WorkerWorkdirLinkRecord {
connection_id: String::new(),
workspace_id: "local-dev".to_string(),
worker: worker.worker.clone(),
workdir_id: workdir.workdir_id.clone(),
@@ -20883,12 +21368,10 @@ INSERT INTO worker_registry (
store.attach_worker_workdir(&link),
Err(Error::WorkdirAttachmentConflict(_))
));
assert_eq!(
store
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
.unwrap(),
link
);
let link = store
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
.unwrap();
assert!(!link.connection_id.is_empty());
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
let downgraded_link = WorkerWorkdirLinkRecord {
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
@@ -20958,10 +21441,7 @@ INSERT INTO worker_registry (
store.attach_worker_workdir(&invalid_capabilities),
Err(Error::InvalidInput(_))
));
assert_eq!(
store.attach_worker_workdir(&second_attachment).unwrap(),
second_attachment
);
let second_attachment = store.attach_worker_workdir(&second_attachment).unwrap();
assert_eq!(
store
.list_worker_workdir_links("local-dev", &worker.worker)
@@ -20991,6 +21471,157 @@ INSERT INTO worker_registry (
store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",),
Err(Error::WorkdirAttachmentConflict(_))
));
let old_connection = downgraded_link.connection_id.clone();
let removed = store
.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"5",
)
.unwrap();
store.restore_worker_workdir_connection(&removed).unwrap();
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap()[0],
downgraded_link
);
store
.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"5",
)
.unwrap();
// Reusing the exact record, including alias, Workdir ID and timestamp, starts a fresh lifetime.
let replacement = store.attach_worker_workdir(&downgraded_link).unwrap();
assert_ne!(replacement.connection_id, old_connection);
assert!(matches!(
store.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"6",
),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.restore_worker_workdir_connection(&removed),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.replace_worker_workdir_link_capabilities(
"local-dev",
&worker.worker,
&[downgraded_link.clone(), second_attachment.clone()],
),
Err(Error::WorkdirAttachmentConflict(_))
));
assert_eq!(store.attach_worker_workdir(&link).unwrap(), replacement);
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap(),
vec![replacement.clone()]
);
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 1, None)
.unwrap(),
vec![second_attachment.clone()]
);
assert!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 2, None)
.unwrap()
.is_empty()
);
assert!(
store
.list_worker_workdir_links_page("foreign", &worker.worker, 1, 0, None)
.unwrap()
.is_empty()
);
assert!(
store
.list_worker_workdir_links_page(
"local-dev",
&RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
1,
0,
None
)
.unwrap()
.is_empty()
);
assert_eq!(
store
.list_worker_workdir_links_page(
"local-dev",
&worker.worker,
1,
0,
Some(&second_attachment.connection_id)
)
.unwrap(),
vec![second_attachment.clone()],
"connection filtering must precede paging"
);
for (workspace_id, identity, connection_id) in [
(
"foreign",
worker.worker.clone(),
second_attachment.connection_id.as_str(),
),
(
"local-dev",
second_worker.worker.clone(),
second_attachment.connection_id.as_str(),
),
(
"local-dev",
RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
second_attachment.connection_id.as_str(),
),
("local-dev", worker.worker.clone(), old_connection.as_str()),
("local-dev", worker.worker.clone(), "nonexistent"),
] {
assert!(
store
.list_worker_workdir_links_page(
workspace_id,
&identity,
1,
0,
Some(connection_id)
)
.unwrap()
.is_empty()
);
}
drop(store);
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap(),
vec![replacement]
);
assert!(matches!(
store.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"6",
),
Err(Error::WorkdirAttachmentConflict(_))
));
let detached = store
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
.unwrap()
@@ -21001,10 +21632,7 @@ INSERT INTO worker_registry (
.worker_workdir_link_history_exists("local-dev", &worker.worker)
.unwrap()
);
assert_eq!(
store.attach_worker_workdir(&workdir_conflict).unwrap(),
workdir_conflict
);
let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap();
drop(store);
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
+310 -64
View File
@@ -11,7 +11,7 @@ use std::collections::HashSet;
use chrono::{SecondsFormat, Utc};
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef};
use rusqlite::{OptionalExtension, Transaction, params};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
pub const MAX_STAGING_ANCHORS: usize = 10;
/// Maximum UTF-8 size of the user-managed Subject behavior document.
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
/// Deterministic surface-generation policy. Input estimates use the repository's
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
/// payload is capped below the whole-input budget to reserve room for the fixed
@@ -74,6 +76,14 @@ pub enum SubjektivError {
expected: u64,
actual: u64,
},
#[error(
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
)]
SubjectBehaviorConflict {
subject_id: String,
expected: u64,
actual: u64,
},
#[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
pub schema_version: u32,
pub id: String,
pub role: SubjectRole,
/// User-managed, host-injected behavior document. This is not Memory and
/// Memory lifecycle operations must preserve it byte-for-byte.
#[serde(default)]
pub behavior_md: String,
/// Monotonic CAS generation for behavior updates, independent of Memory's
/// `store_revision`.
#[serde(default)]
pub behavior_revision: u64,
pub state: SubjectState,
/// Monotonic generation incremented exactly once for every committed Memory
/// creation or revision for this subject.
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
pub snapshot: Option<SurfaceSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectResidentContext {
pub subject: SubjectRecord,
pub surface: ResidentSurface,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryRevisionTarget {
@@ -986,6 +1010,17 @@ END;
Ok(())
}
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
transaction.execute_batch(
r#"
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
CHECK (behavior_revision >= 0);
"#,
)?;
Ok(())
}
static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
FeatureMigration::new(
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add bounded surface generation state and runs",
add_surface_generation_state,
),
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
];
pub const REGISTRATION: FeatureRegistration =
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
/// Issues a random host-owned subject id. It is never derived from Worker,
/// Profile, Session, or Memory content.
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
self.create_subject_with_behavior(role, String::new())
}
/// Creates a Subject with its optional user-managed behavior document.
pub fn create_subject_with_behavior(
&self,
role: SubjectRole,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
let timestamp = now();
let record = SubjectRecord {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: issued_id("subject"),
role,
behavior_md,
behavior_revision: 0,
state: SubjectState::Active,
store_revision: 0,
created_at: timestamp.clone(),
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
self.database.try_transaction(|transaction| {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
subject_id, role, behavior_md, behavior_revision, state,
store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
params![
record.id,
record.role.as_str(),
record.behavior_md,
raw,
record.created_at,
record.updated_at
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
})
}
/// Replaces or clears the user-managed behavior document using its own CAS
/// generation. Memory revisions do not participate in this generation.
pub fn update_subject_behavior(
&self,
subject_id: &str,
expected_behavior_revision: u64,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
self.database.try_transaction(|transaction| {
let mut subject = require_subject(transaction, subject_id)?;
if subject.behavior_revision != expected_behavior_revision {
return Err(SubjektivError::SubjectBehaviorConflict {
subject_id: subject_id.to_string(),
expected: expected_behavior_revision,
actual: subject.behavior_revision,
});
}
if subject.behavior_md == behavior_md {
return Ok(subject);
}
subject.behavior_md = behavior_md;
subject.behavior_revision =
subject.behavior_revision.checked_add(1).ok_or_else(|| {
SubjektivError::InvalidRecord("subject behavior revision overflow".to_string())
})?;
subject.updated_at = now();
let raw = serde_json::to_string(&subject)?;
transaction.execute(
"UPDATE subjects
SET behavior_md = ?2, behavior_revision = ?3,
record_json = ?4, updated_at = ?5
WHERE subject_id = ?1",
params![
subject.id,
subject.behavior_md,
to_i64(subject.behavior_revision)?,
raw,
subject.updated_at
],
)?;
Ok(subject)
})
}
/// Validates an explicit product launch connection against this Workspace's
/// subject store and the already-resolved Profile policy. Subject knowledge
/// alone is insufficient: only an active local record produces host launch material.
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
self.database.try_with_connection(|connection| {
let subject = require_subject_in_connection(connection, subject_id)?;
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[subject_id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision).map_err(|_| {
SubjektivError::InvalidRecord("surface state revision is negative".into())
})?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject_id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
resident_surface_in_connection(connection, &subject)
})
}
/// Atomically reads user-managed behavior and the independently-fresh
/// generated Memory surface from one SQLite connection snapshot.
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
self.database.try_transaction(|transaction| {
let subject = require_subject(transaction, subject_id)?;
let surface = resident_surface_in_connection(transaction, &subject)?;
Ok(SubjectResidentContext { subject, surface })
})
}
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
}
}
fn resident_surface_in_connection(
connection: &Connection,
subject: &SubjectRecord,
) -> Result<ResidentSurface> {
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[&subject.id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision)
.map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject.id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
}
pub fn validate_subject_behavior(value: &str) -> Result<()> {
if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES {
return Err(SubjektivError::InvalidRecord(format!(
"subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes"
)));
}
if !value.is_empty() && value.trim().is_empty() {
return Err(SubjektivError::InvalidRecord(
"subject behavior must be empty or contain non-whitespace text".to_string(),
));
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(SubjektivError::InvalidRecord(
"subject behavior contains an unsupported control character".to_string(),
));
}
Ok(())
}
fn validate_label(kind: &str, value: &str) -> Result<()> {
validate_nonempty(kind, value)?;
if value.len() > 256 || value.chars().any(char::is_control) {
@@ -3668,6 +3801,67 @@ mod tests {
assert!(!complete.has_more);
}
#[test]
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
let subject = store
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
.unwrap();
assert_eq!(subject.behavior_revision, 0);
assert_eq!(subject.store_revision, 0);
let updated = store
.update_subject_behavior(
&subject.id,
0,
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
)
.unwrap();
assert_eq!(updated.behavior_revision, 1);
assert_eq!(updated.store_revision, 0);
store
.create_memory(
&subject.id,
draft(
"A confirmed observation",
"exercise Memory lifecycle isolation",
),
)
.unwrap();
let after_memory = store.subject(&subject.id).unwrap().unwrap();
assert_eq!(after_memory.behavior_md, updated.behavior_md);
assert_eq!(after_memory.behavior_revision, 1);
assert_eq!(after_memory.store_revision, 1);
let (_reopened_manager, _reopened_workspace, reopened) =
open_store(temp.path(), "workspace-a");
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
assert_eq!(persisted.behavior_md, updated.behavior_md);
assert_eq!(persisted.behavior_revision, 1);
assert!(matches!(
store.update_subject_behavior(&subject.id, 0, String::new()),
Err(SubjektivError::SubjectBehaviorConflict {
expected: 0,
actual: 1,
..
})
));
let unchanged = store
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
.unwrap();
assert_eq!(unchanged.behavior_revision, 1);
let cleared = store
.update_subject_behavior(&subject.id, 1, String::new())
.unwrap();
assert_eq!(cleared.behavior_revision, 2);
assert!(cleared.behavior_md.is_empty());
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
assert!(validate_subject_behavior(" ").is_err());
}
#[test]
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
let temp = tempfile::tempdir().unwrap();
@@ -3892,10 +4086,38 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap();
store.create_subject(role()).unwrap().id
let timestamp = now();
let subject_id = issued_id("subject");
let mut raw = serde_json::json!({
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
"id": subject_id,
"role": role(),
"state": SubjectState::Active,
"store_revision": 0,
"created_at": timestamp.clone(),
"updated_at": timestamp.clone(),
});
let raw = serde_json::to_string(&raw.take()).unwrap();
store
.database
.try_transaction(|transaction| -> Result<()> {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision,
record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
params![subject_id, role().as_str(), raw, timestamp, timestamp],
)?;
Ok(())
})
.unwrap();
subject_id
};
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
assert!(migrated_subject.behavior_md.is_empty());
assert_eq!(migrated_subject.behavior_revision, 0);
let lifecycle_attribution = attribution(
&subject_id,
"runtime-1",
@@ -3935,26 +4157,50 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap();
let subject = store.create_subject(role()).unwrap();
let subject_id = issued_id("subject");
let timestamp = now();
let subject_json = serde_json::to_string(&serde_json::json!({
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
"id": subject_id,
"role": role(),
"state": SubjectState::Active,
"store_revision": 0,
"created_at": timestamp,
"updated_at": timestamp,
}))
.unwrap();
let snapshot = SurfaceSnapshot {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: "legacy-surface".into(),
subject_id: subject.id.clone(),
subject_id: subject_id.clone(),
body_md: "Legacy summary without generation-policy evidence.".into(),
memory_refs: Vec::new(),
built_from_store_revision: subject.store_revision,
built_from_store_revision: 0,
created_at: now(),
};
store
.database
.try_transaction(|transaction| -> Result<()> {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision,
record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
params![
subject_id,
role().as_str(),
subject_json,
timestamp,
timestamp
],
)?;
transaction.execute(
"INSERT INTO surface_snapshots (
subject_id, snapshot_id, built_from_store_revision,
snapshot_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
subject.id,
subject_id,
snapshot.id,
to_i64(snapshot.built_from_store_revision)?,
serde_json::to_string(&snapshot)?,
@@ -3964,12 +4210,12 @@ mod tests {
transaction.execute(
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
VALUES (?1, ?2)",
params![subject.id, snapshot.id],
params![subject_id, snapshot.id],
)?;
Ok(())
})
.unwrap();
(subject.id, snapshot.id)
(subject_id, snapshot.id)
};
let (_manager, _workspace, store) = open_store(&root, "workspace-a");