fix: integrate develop and preserve invocation notification recovery
This commit is contained in:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod history;
|
||||
mod image_recovery;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, ..
|
||||
} => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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, ¬ify_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,
|
||||
¬ify_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
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -121,6 +121,7 @@ impl SegmentLogSink {
|
||||
| LogEntry::AnnotatedUserInput { .. }
|
||||
| LogEntry::AnnotatedAssistantItem { .. }
|
||||
| LogEntry::AnnotatedToolResult { .. }
|
||||
| LogEntry::ToolResultCorrected { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+967
-43
File diff suppressed because it is too large
Load Diff
+2449
-152
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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, ®istration).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, ®istration).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");
|
||||
|
||||
Reference in New Issue
Block a user