fix: integrate develop and preserve invocation notification recovery
This commit is contained in:
Generated
+7
@@ -64,6 +64,7 @@ dependencies = [
|
|||||||
"dotenv",
|
"dotenv",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
|
"imagesize",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2188,6 +2189,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "include_dir"
|
name = "include_dir"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
|
|||||||
eventsource-stream = "0.2"
|
eventsource-stream = "0.2"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
imagesize = "0.14"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||||
agen-macros = { workspace = 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
|
/// engine history. An error rejects the item and aborts the turn, allowing
|
||||||
/// upper layers to make durable storage the commit gate.
|
/// upper layers to make durable storage the commit gate.
|
||||||
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
|
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 configuration (max_tokens, temperature, etc.)
|
||||||
request_config: RequestConfig,
|
request_config: RequestConfig,
|
||||||
/// Cancel notification channel (for interrupting execution)
|
/// 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));
|
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> {
|
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
|
||||||
for cb in &self.history_append_cbs {
|
for cb in &self.history_append_cbs {
|
||||||
cb(item).map_err(EngineError::HistoryAppend)?;
|
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"
|
"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();
|
self.tool_call_collector.begin_response();
|
||||||
let mut stream = ResponseStreamPump::start(stream);
|
let mut stream = ResponseStreamPump::start(stream);
|
||||||
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
|
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(),
|
warning_cbs: Vec::new(),
|
||||||
tool_result_cbs: Vec::new(),
|
tool_result_cbs: Vec::new(),
|
||||||
history_append_cbs: Vec::new(),
|
history_append_cbs: Vec::new(),
|
||||||
|
image_rejection_handler: None,
|
||||||
request_config: RequestConfig::default(),
|
request_config: RequestConfig::default(),
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
cancel_rx,
|
cancel_rx,
|
||||||
@@ -3856,6 +3898,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
|||||||
warning_cbs: self.warning_cbs,
|
warning_cbs: self.warning_cbs,
|
||||||
tool_result_cbs: self.tool_result_cbs,
|
tool_result_cbs: self.tool_result_cbs,
|
||||||
history_append_cbs: self.history_append_cbs,
|
history_append_cbs: self.history_append_cbs,
|
||||||
|
image_rejection_handler: self.image_rejection_handler,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
cancel_tx: self.cancel_tx,
|
||||||
@@ -4040,6 +4083,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
|
|||||||
warning_cbs: self.warning_cbs,
|
warning_cbs: self.warning_cbs,
|
||||||
tool_result_cbs: self.tool_result_cbs,
|
tool_result_cbs: self.tool_result_cbs,
|
||||||
history_append_cbs: self.history_append_cbs,
|
history_append_cbs: self.history_append_cbs,
|
||||||
|
image_rejection_handler: self.image_rejection_handler,
|
||||||
request_config: self.request_config,
|
request_config: self.request_config,
|
||||||
|
|
||||||
cancel_tx: self.cancel_tx,
|
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 engine;
|
||||||
mod handler;
|
mod handler;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod image_recovery;
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
pub(crate) mod callback;
|
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> {
|
pub fn retry_after(&self) -> Option<Duration> {
|
||||||
match self {
|
match self {
|
||||||
ClientError::Api { retry_after, .. } => *retry_after,
|
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.
|
/// 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
|
/// 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
|
/// immediate follow-up request and remain visible. With early dispatch, later
|
||||||
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
|
/// text/calls in the same response have not consumed its tool results: use the
|
||||||
/// requests keep the durable tool summary and can call the originating tool again when the image
|
/// confirmed response boundary, not that response's last individual item.
|
||||||
/// is still needed. Only the request-context clone is modified.
|
/// 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 {
|
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
|
||||||
let latest_model_output = items.iter().rposition(|item| {
|
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
|
||||||
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
|
|
||||||
});
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (index, item) in items.iter_mut().enumerate() {
|
for (index, item) in items.iter_mut().enumerate() {
|
||||||
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
|
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.
|
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
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
|
- [`../../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.
|
/// is disabled; `Some` requires `default_action` during final resolve.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Option<PermissionConfigPartial>,
|
pub permissions: Option<PermissionConfigPartial>,
|
||||||
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
|
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
|
||||||
/// disabled after cascade merge.
|
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
|
||||||
|
/// enabled without granting authority.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfigPartial,
|
pub feature: FeatureConfigPartial,
|
||||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||||
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||||
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
|
|||||||
other.manage_workdir,
|
other.manage_workdir,
|
||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
|
workdir_catalog: merge_option(
|
||||||
|
self.workdir_catalog,
|
||||||
|
other.workdir_catalog,
|
||||||
|
FeatureFlagConfigPartial::merge,
|
||||||
|
),
|
||||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||||
merge_request: merge_option(
|
merge_request: merge_option(
|
||||||
self.merge_request,
|
self.merge_request,
|
||||||
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.manage_workdir
|
.manage_workdir
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
workdir_catalog: FeatureFlagConfig {
|
||||||
|
enabled: value
|
||||||
|
.workdir_catalog
|
||||||
|
.and_then(|flag| flag.enabled)
|
||||||
|
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||||
|
},
|
||||||
ticket: value
|
ticket: value
|
||||||
.ticket
|
.ticket
|
||||||
.map(TicketFeatureConfig::from)
|
.map(TicketFeatureConfig::from)
|
||||||
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||||
objective: Some(value.objective.into()),
|
objective: Some(value.objective.into()),
|
||||||
manage_workdir: Some(value.manage_workdir.into()),
|
manage_workdir: Some(value.manage_workdir.into()),
|
||||||
|
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
merge_request: Some(value.merge_request.into()),
|
merge_request: Some(value.merge_request.into()),
|
||||||
orchestration: Some(value.orchestration.into()),
|
orchestration: Some(value.orchestration.into()),
|
||||||
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
|
|||||||
|
|
||||||
/// Base config populated with the in-code per-field defaults listed in
|
/// Base config populated with the in-code per-field defaults listed in
|
||||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
/// [`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
|
/// resolvers start from this layer so every per-field default lives at
|
||||||
/// exactly one call site (the `defaults` module).
|
/// exactly one call site (the `defaults` module).
|
||||||
///
|
///
|
||||||
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert!(!manifest.feature.task.enabled);
|
assert!(!manifest.feature.task.enabled);
|
||||||
assert!(!manifest.feature.memory.profile.enabled);
|
assert!(!manifest.feature.memory.profile.enabled);
|
||||||
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
|
|||||||
assert!(!manifest.feature.sub_worker.enabled);
|
assert!(!manifest.feature.sub_worker.enabled);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
assert!(!manifest.feature.manage_workdir.enabled);
|
assert!(!manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(manifest.feature.workdir_catalog.enabled);
|
||||||
assert!(!manifest.feature.ticket.enabled);
|
assert!(!manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.merge_request.any());
|
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]
|
#[test]
|
||||||
fn from_toml_parses_explicit_feature_flags() {
|
fn from_toml_parses_explicit_feature_flags() {
|
||||||
let cfg = WorkerManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
//! `pub const`. Both paths read from this module, so changing a
|
//! `pub const`. Both paths read from this module, so changing a
|
||||||
//! default requires editing exactly one line.
|
//! 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
|
/// Byte-size cap applied to any tool's `content` output when no
|
||||||
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
||||||
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
|
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.
|
/// permission layer is disabled and tool calls run as before.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Option<ToolPermissionConfig>,
|
pub permissions: Option<ToolPermissionConfig>,
|
||||||
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
|
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
|
||||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
/// disabled; the read-only WIP workdir catalog defaults enabled without
|
||||||
|
/// granting authority or registering normal Tools.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfig,
|
pub feature: FeatureConfig,
|
||||||
/// Explicit external Model Context Protocol provider configuration. This
|
/// Explicit external Model Context Protocol provider configuration. This
|
||||||
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
|
|||||||
pub objective: FeatureFlagConfig,
|
pub objective: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manage_workdir: FeatureFlagConfig,
|
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)]
|
#[serde(default)]
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
|
|||||||
pub orchestration: FeatureFlagConfig,
|
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 {
|
impl Default for FeatureConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
|
|||||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||||
objective: FeatureFlagConfig::disabled(),
|
objective: FeatureFlagConfig::disabled(),
|
||||||
manage_workdir: FeatureFlagConfig::disabled(),
|
manage_workdir: FeatureFlagConfig::disabled(),
|
||||||
|
workdir_catalog: default_workdir_catalog(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
merge_request: MergeRequestFeatureConfig::default(),
|
merge_request: MergeRequestFeatureConfig::default(),
|
||||||
orchestration: FeatureFlagConfig::disabled(),
|
orchestration: FeatureFlagConfig::disabled(),
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
|
|||||||
if feature.flow.enabled {
|
if feature.flow.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
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 {
|
if feature.manage_workdir.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||||
}
|
}
|
||||||
@@ -1586,6 +1588,59 @@ mod tests {
|
|||||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
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]
|
#[test]
|
||||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -190,8 +190,14 @@ impl_openapi_schema!(
|
|||||||
WorkingDirectoryListResponse,
|
WorkingDirectoryListResponse,
|
||||||
WorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse,
|
||||||
WorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse,
|
||||||
|
CurrentWorkerWorkdirCatalogQuery,
|
||||||
|
CurrentWorkerWorkdirCatalogResponse,
|
||||||
CurrentWorkerWorkdirAttachRequest,
|
CurrentWorkerWorkdirAttachRequest,
|
||||||
CurrentWorkerWorkdirAttachmentResponse,
|
CurrentWorkerWorkdirAttachmentResponse,
|
||||||
|
CurrentWorkerWorkdirAttachmentItem,
|
||||||
|
CurrentWorkerWorkdirAttachmentListQuery,
|
||||||
|
CurrentWorkerWorkdirAttachmentListResponse,
|
||||||
|
CurrentWorkerWorkdirDetachQuery,
|
||||||
CurrentWorkerWorkdirOperationRequest,
|
CurrentWorkerWorkdirOperationRequest,
|
||||||
CurrentWorkerWorkdirOperationResponse,
|
CurrentWorkerWorkdirOperationResponse,
|
||||||
ExternalWorkdirGrantCreateRequest,
|
ExternalWorkdirGrantCreateRequest,
|
||||||
@@ -237,6 +243,7 @@ impl_openapi_schema!(
|
|||||||
MemoryConsolidateStagingRequest,
|
MemoryConsolidateStagingRequest,
|
||||||
MemoryConsolidationResponse,
|
MemoryConsolidationResponse,
|
||||||
SubjektivSubjectCreateRequest,
|
SubjektivSubjectCreateRequest,
|
||||||
|
SubjektivSubjectBehaviorUpdateRequest,
|
||||||
SubjektivSubjectListQuery,
|
SubjektivSubjectListQuery,
|
||||||
SubjektivSubjectListResponse,
|
SubjektivSubjectListResponse,
|
||||||
SubjektivSubjectResponse,
|
SubjektivSubjectResponse,
|
||||||
@@ -1163,6 +1170,20 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] subject_id: String,
|
#[path] subject_id: String,
|
||||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
) -> 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(
|
#[get(
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -2549,6 +2570,32 @@ pub trait ServerApi {
|
|||||||
#[path] working_directory_id: String,
|
#[path] working_directory_id: String,
|
||||||
#[body] request: WorkingDirectoryRemovalRequest,
|
#[body] request: WorkingDirectoryRemovalRequest,
|
||||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
) -> 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(
|
#[post(
|
||||||
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -2574,6 +2621,7 @@ pub trait ServerApi {
|
|||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] alias: String,
|
#[path] alias: String,
|
||||||
|
#[query] query: CurrentWorkerWorkdirDetachQuery,
|
||||||
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
|
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
|
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
|
||||||
@@ -4579,9 +4627,20 @@ pub enum SubjektivSubjectState {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SubjektivSubjectCreateRequest {
|
pub struct SubjektivSubjectCreateRequest {
|
||||||
pub role: String,
|
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_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||||
|
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -4609,6 +4668,9 @@ pub struct SubjektivSubjectListResponse {
|
|||||||
pub struct SubjektivSubjectResponse {
|
pub struct SubjektivSubjectResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub role: 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,
|
pub state: SubjektivSubjectState,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub store_revision: u64,
|
pub store_revision: u64,
|
||||||
@@ -5442,10 +5504,22 @@ pub struct SubjektivSurfaceFailureResponse {
|
|||||||
pub status: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||||
pub enum SubjektivMemoryBackendOperation {
|
pub enum SubjektivMemoryBackendOperation {
|
||||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||||
|
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
|
||||||
Query(SubjektivMemoryQueryRequest),
|
Query(SubjektivMemoryQueryRequest),
|
||||||
Read(SubjektivMemoryReadRequest),
|
Read(SubjektivMemoryReadRequest),
|
||||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||||
@@ -5618,6 +5692,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
|||||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||||
pub enum SubjektivMemoryBackendResponse {
|
pub enum SubjektivMemoryBackendResponse {
|
||||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||||
|
ResidentContext(SubjektivResidentContextOutput),
|
||||||
Query(SubjektivMemoryQueryResponse),
|
Query(SubjektivMemoryQueryResponse),
|
||||||
Read(SubjektivMemoryReadResponse),
|
Read(SubjektivMemoryReadResponse),
|
||||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||||
@@ -6754,6 +6829,69 @@ pub struct CurrentWorkerWorkdirAttachRequest {
|
|||||||
pub working_directory_id: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CurrentWorkerWorkdirAttachmentResponse {
|
pub struct CurrentWorkerWorkdirAttachmentResponse {
|
||||||
@@ -9869,9 +10007,11 @@ pub fn memory_api_typescript() -> String {
|
|||||||
// `ts-rs` solely for this read-only projection.
|
// `ts-rs` solely for this read-only projection.
|
||||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
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, };
|
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!(
|
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;"
|
"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!(
|
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
|
declarations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|declaration| format!("export {declaration}"))
|
.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]
|
#[test]
|
||||||
fn workspace_and_repository_response_shapes_round_trip() {
|
fn workspace_and_repository_response_shapes_round_trip() {
|
||||||
let workspace = serde_json::json!({
|
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
|
// 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.
|
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
|
||||||
const SIGNED_INTERNAL: &[&str] = &[
|
const SIGNED_INTERNAL: &[&str] = &[
|
||||||
|
"current_worker_workdir_catalog",
|
||||||
|
"current_worker_workdir_attachment_list",
|
||||||
"current_worker_workdir_attach",
|
"current_worker_workdir_attach",
|
||||||
"current_worker_workdir_detach",
|
"current_worker_workdir_detach",
|
||||||
"current_worker_workdir_operation",
|
"current_worker_workdir_operation",
|
||||||
|
|||||||
@@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
|
|||||||
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
||||||
LogEntry::AnnotatedAssistantItem { .. }
|
LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use protocol::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -139,6 +139,17 @@ struct RetainedHistoryCursor {
|
|||||||
before_offset: u64,
|
before_offset: u64,
|
||||||
record_end_offset: Option<u64>,
|
record_end_offset: Option<u64>,
|
||||||
seed_entry_index: Option<usize>,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -445,6 +456,7 @@ pub fn read_retained_session_history_page(
|
|||||||
limits,
|
limits,
|
||||||
&mut scanned_bytes,
|
&mut scanned_bytes,
|
||||||
&mut scanned_entries,
|
&mut scanned_entries,
|
||||||
|
&mut Vec::new(),
|
||||||
)?;
|
)?;
|
||||||
Some(SessionHistoryLineageBoundary {
|
Some(SessionHistoryLineageBoundary {
|
||||||
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
|
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
|
||||||
@@ -456,6 +468,10 @@ pub fn read_retained_session_history_page(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let mut correction_positions = cursor
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.corrections.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let mut turns = read_history_turns_backward(
|
let mut turns = read_history_turns_backward(
|
||||||
&store,
|
&store,
|
||||||
active.session_id,
|
active.session_id,
|
||||||
@@ -465,11 +481,23 @@ pub fn read_retained_session_history_page(
|
|||||||
limits,
|
limits,
|
||||||
&mut scanned_bytes,
|
&mut scanned_bytes,
|
||||||
&mut scanned_entries,
|
&mut scanned_entries,
|
||||||
|
&mut correction_positions,
|
||||||
)?;
|
)?;
|
||||||
let has_more = turns.len() > page_limit;
|
let has_more = turns.len() > page_limit;
|
||||||
if has_more {
|
if has_more {
|
||||||
turns.truncate(page_limit);
|
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 next_cursor = if has_more {
|
||||||
let oldest = turns
|
let oldest = turns
|
||||||
.last()
|
.last()
|
||||||
@@ -485,6 +513,7 @@ pub fn read_retained_session_history_page(
|
|||||||
before_offset: oldest.position.before_offset,
|
before_offset: oldest.position.before_offset,
|
||||||
record_end_offset: oldest.position.record_end_offset,
|
record_end_offset: oldest.position.record_end_offset,
|
||||||
seed_entry_index: oldest.position.seed_entry_index,
|
seed_entry_index: oldest.position.seed_entry_index,
|
||||||
|
corrections: correction_positions,
|
||||||
})?)
|
})?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
|
|||||||
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
|
||||||
LogEntry::AnnotatedAssistantItem { .. }
|
LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
| LogEntry::AnnotatedSystemItem { .. } => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
@@ -814,10 +844,56 @@ fn read_history_turns_backward(
|
|||||||
limits: RetainedHistoryReadLimits,
|
limits: RetainedHistoryReadLimits,
|
||||||
scanned_bytes: &mut u64,
|
scanned_bytes: &mut u64,
|
||||||
scanned_entries: &mut usize,
|
scanned_entries: &mut usize,
|
||||||
|
correction_positions: &mut Vec<ToolCorrectionPosition>,
|
||||||
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
|
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
|
||||||
let mut turns = Vec::new();
|
let mut turns = Vec::new();
|
||||||
let mut pending_entries = Vec::new();
|
let mut pending_entries = Vec::new();
|
||||||
let mut seen_entries = HashSet::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 {
|
let (start_segment_index, start_position, cursor_turn_id) = match start {
|
||||||
Some((position, turn_id)) => {
|
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) {
|
for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) {
|
||||||
let starting_here = segment_index == 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(
|
let adopted_end = locate_adopted_segment_end(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -923,6 +973,32 @@ fn read_history_turns_backward(
|
|||||||
if before_offset > adopted_end {
|
if before_offset > adopted_end {
|
||||||
return Err(RetainedHistoryReadError::InvalidCursor);
|
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
|
let mut reader = store
|
||||||
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
|
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
|
||||||
.map_err(map_history_store_error)?;
|
.map_err(map_history_store_error)?;
|
||||||
@@ -949,7 +1025,25 @@ fn read_history_turns_backward(
|
|||||||
}
|
}
|
||||||
continue;
|
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(
|
if collect_history_entries_backward(
|
||||||
&entries,
|
&entries,
|
||||||
segment.segment_id,
|
segment.segment_id,
|
||||||
@@ -967,7 +1061,7 @@ fn read_history_turns_backward(
|
|||||||
|
|
||||||
if segment_index + 1 == lineage.len()
|
if segment_index + 1 == lineage.len()
|
||||||
&& collect_history_entries_backward(
|
&& collect_history_entries_backward(
|
||||||
&segment.seed_entries,
|
&corrected_history_entries(&segment.seed_entries, &corrections),
|
||||||
segment.segment_id,
|
segment.segment_id,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
@@ -984,6 +1078,38 @@ fn read_history_turns_backward(
|
|||||||
Ok(turns)
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn collect_history_entries_backward(
|
fn collect_history_entries_backward(
|
||||||
entries: &[SessionSnapshotEntry],
|
entries: &[SessionSnapshotEntry],
|
||||||
@@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool {
|
|||||||
fn encode_history_cursor(
|
fn encode_history_cursor(
|
||||||
cursor: &RetainedHistoryCursor,
|
cursor: &RetainedHistoryCursor,
|
||||||
) -> Result<String, RetainedHistoryReadError> {
|
) -> Result<String, RetainedHistoryReadError> {
|
||||||
serde_json::to_vec(cursor)
|
let encoded = serde_json::to_vec(cursor)
|
||||||
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
|
.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> {
|
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));
|
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(
|
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
|
||||||
&entry.item,
|
&entry.item,
|
||||||
entry.metadata.entry_id.0.clone(),
|
entry.metadata.entry_id.0.clone(),
|
||||||
@@ -1782,6 +1925,129 @@ mod tests {
|
|||||||
.collect()
|
.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]
|
#[test]
|
||||||
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
|
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
|
||||||
let worker_name = "worker-history-12";
|
let worker_name = "worker-history-12";
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ pub enum LogEntry {
|
|||||||
|
|
||||||
/// Canonical tool output and metadata committed as one journal record.
|
/// Canonical tool output and metadata committed as one journal record.
|
||||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
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
|
/// Canonical typed system event and model-visible metadata committed
|
||||||
/// together.
|
/// together.
|
||||||
@@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.annotated_history.push(entry.clone());
|
state.annotated_history.push(entry.clone());
|
||||||
state.history.push(Item::from(entry.item.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 {
|
LogEntry::AnnotatedSystemItem {
|
||||||
entry, extensions, ..
|
entry, extensions, ..
|
||||||
} => {
|
} => {
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ pub enum SystemItem {
|
|||||||
prompt_provenance: Option<PromptRenderProvenance>,
|
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
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
/// previous turn was cut short.
|
/// previous turn was cut short.
|
||||||
@@ -201,6 +210,7 @@ impl SystemItem {
|
|||||||
}
|
}
|
||||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||||
|
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +234,7 @@ impl SystemItem {
|
|||||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||||
|
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
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]
|
#[test]
|
||||||
fn notification_history_text_returns_stored_body() {
|
fn notification_history_text_returns_stored_body() {
|
||||||
let item = SystemItem::Notification {
|
let item = SystemItem::Notification {
|
||||||
|
|||||||
@@ -3042,6 +3042,7 @@ impl App {
|
|||||||
| session_store::SystemItem::FileAttachment { body, .. }
|
| session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::SkillActivation { body, .. }
|
| session_store::SystemItem::SkillActivation { body, .. }
|
||||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||||
|
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { text: body });
|
self.blocks.push(Block::SystemMessage { text: body });
|
||||||
|
|||||||
@@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_with_permission(
|
fn execute_with_optional_timeout(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
permission: &'static str,
|
timeout: Option<Duration>,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
let method = match request.method {
|
let method = match request.method {
|
||||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||||
@@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
path_and_query: request.path,
|
path_and_query: request.path,
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
permission: permission.to_string(),
|
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||||
worker_id: Some(self.worker_id.clone()),
|
worker_id: Some(self.worker_id.clone()),
|
||||||
timeout: self.request_timeout,
|
timeout,
|
||||||
max_response_bytes: 8 * 1024 * 1024,
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> 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(
|
fn execute_server_operation(
|
||||||
@@ -1341,6 +1349,25 @@ mod tests {
|
|||||||
server.join().unwrap();
|
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]
|
#[test]
|
||||||
fn runtime_owned_workspace_client_reports_request_timeouts() {
|
fn runtime_owned_workspace_client_reports_request_timeouts() {
|
||||||
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
|
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
|
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>(
|
fn notification_coalesce_remaining<St: Store + Clone>(
|
||||||
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
||||||
notify_buffer: &NotifyBuffer,
|
notify_buffer: &NotifyBuffer,
|
||||||
delay: Duration,
|
delay: Duration,
|
||||||
|
can_schedule_run: bool,
|
||||||
) -> Option<Duration> {
|
) -> Option<Duration> {
|
||||||
|
if !can_schedule_run {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
|
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
|
||||||
else {
|
else {
|
||||||
return notify_buffer
|
return notify_buffer
|
||||||
@@ -1434,6 +1452,7 @@ where
|
|||||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||||
worker.manifest(),
|
worker.manifest(),
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
|
worker.prompts(),
|
||||||
)?
|
)?
|
||||||
{
|
{
|
||||||
let workspace_id = worker
|
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
|
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||||
// repository paths, materializer handles, or cleanup sessions.
|
// repository paths, materializer handles, or cleanup sessions.
|
||||||
@@ -1579,7 +1604,7 @@ where
|
|||||||
}
|
}
|
||||||
let shutdown_registry = spawned_registry.clone();
|
let shutdown_registry = spawned_registry.clone();
|
||||||
let reopen_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(
|
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||||
workspace_client,
|
workspace_client,
|
||||||
worker.workdir_sessions(),
|
worker.workdir_sessions(),
|
||||||
@@ -1588,8 +1613,11 @@ where
|
|||||||
Box::pin(async move { child_registry.shutdown_internal().await })
|
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||||
}),
|
}),
|
||||||
Arc::new(move || reopen_registry.reopen_internal()),
|
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 {
|
if feature_config.workspace_worker_discovery.enabled {
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
@@ -1644,20 +1672,34 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
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 wip_permissions = worker.manifest().permissions.clone();
|
||||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
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 workspace_client = worker.workspace_client_handle();
|
||||||
let engine = worker.engine_mut();
|
let engine = worker.engine_mut();
|
||||||
|
|
||||||
if feature_config.merge_request.any() && wip_mode {
|
if feature_config.merge_request.any() && wip_mode {
|
||||||
let feature_route = wip_mount_registry
|
let namespace_route = wip_mount_registry
|
||||||
.allocate_feature_route("merge-request")
|
.allocate_namespace("merge-request", "merge-requests")
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
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(
|
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||||
@@ -1665,7 +1707,7 @@ where
|
|||||||
workspace_client.clone(),
|
workspace_client.clone(),
|
||||||
feature_config.merge_request,
|
feature_config.merge_request,
|
||||||
wip_permissions.clone(),
|
wip_permissions.clone(),
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
@@ -1682,12 +1724,12 @@ where
|
|||||||
intake: feature_config.ticket.intake,
|
intake: feature_config.ticket.intake,
|
||||||
workflow: feature_config.ticket.workflow,
|
workflow: feature_config.ticket.workflow,
|
||||||
};
|
};
|
||||||
let feature_route = wip_mount_registry
|
let namespace_route = wip_mount_registry
|
||||||
.allocate_feature_route("ticket")
|
.allocate_namespace("ticket", "tickets")
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
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(
|
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||||
@@ -1695,7 +1737,7 @@ where
|
|||||||
workspace_client.clone(),
|
workspace_client.clone(),
|
||||||
ticket_access,
|
ticket_access,
|
||||||
wip_permissions.clone(),
|
wip_permissions.clone(),
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
@@ -1716,19 +1758,19 @@ where
|
|||||||
engine.register_tool(definition);
|
engine.register_tool(definition);
|
||||||
}
|
}
|
||||||
if wip_mode {
|
if wip_mode {
|
||||||
let feature_route = wip_mount_registry
|
let namespace_route = wip_mount_registry
|
||||||
.allocate_feature_route("objective")
|
.allocate_namespace("objective", "objectives")
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
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(
|
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
|
||||||
&mut wip_mount_registry,
|
&mut wip_mount_registry,
|
||||||
workspace_client.clone(),
|
workspace_client.clone(),
|
||||||
wip_permissions.clone(),
|
wip_permissions.clone(),
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
@@ -2140,9 +2182,11 @@ async fn controller_loop<C, St>(
|
|||||||
submission.input,
|
submission.input,
|
||||||
vec![extension],
|
vec![extension],
|
||||||
submission.provenance,
|
submission.provenance,
|
||||||
move || {
|
input_committed_hook(
|
||||||
let _ = input_commit_tx.send(());
|
pending_submissions.clone(),
|
||||||
},
|
committed_submission.submission_id.clone(),
|
||||||
|
input_commit_tx,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&working_event_tx,
|
&working_event_tx,
|
||||||
@@ -2199,10 +2243,6 @@ async fn controller_loop<C, St>(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if notify_buffer.is_empty() {
|
|
||||||
pending_submissions.finish_notification_batch();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
|
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
|
||||||
match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) {
|
match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) {
|
||||||
Ok(Some(next)) => {
|
Ok(Some(next)) => {
|
||||||
@@ -2251,12 +2291,16 @@ async fn controller_loop<C, St>(
|
|||||||
continue;
|
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)
|
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
|
||||||
.then(|| {
|
.then(|| {
|
||||||
notification_coalesce_remaining(
|
notification_coalesce_remaining(
|
||||||
&pending_submissions,
|
&pending_submissions,
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
notification_coalesce_delay,
|
notification_coalesce_delay,
|
||||||
|
worker.can_schedule_notification_run(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.flatten();
|
.flatten();
|
||||||
@@ -3930,6 +3974,40 @@ mod tests {
|
|||||||
assert!(notify_buffer.has_notification_pending());
|
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]
|
#[test]
|
||||||
fn restored_mixed_activations_preserve_global_fifo_order() {
|
fn restored_mixed_activations_preserve_global_fifo_order() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||||
//! behind [`WorkspaceClient`].
|
//! behind [`WorkspaceClient`].
|
||||||
|
|
||||||
|
pub mod wip;
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
|
|||||||
session_router: Arc<workdir::WorkdirSessionRouter>,
|
session_router: Arc<workdir::WorkdirSessionRouter>,
|
||||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||||
|
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||||
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
|
|||||||
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
||||||
before_workdir_release: None,
|
before_workdir_release: None,
|
||||||
after_workdir_attach: None,
|
after_workdir_attach: None,
|
||||||
|
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
|
|||||||
session_router,
|
session_router,
|
||||||
before_workdir_release: Some(before_workdir_release),
|
before_workdir_release: Some(before_workdir_release),
|
||||||
after_workdir_attach: Some(after_workdir_attach),
|
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 {
|
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> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
let backend = self.backend();
|
||||||
.with_session_router(self.session_router.clone())
|
|
||||||
.with_child_lifecycle(
|
|
||||||
self.before_workdir_release.clone(),
|
|
||||||
self.after_workdir_attach.clone(),
|
|
||||||
);
|
|
||||||
for (name, definition) in [
|
for (name, definition) in [
|
||||||
(
|
(
|
||||||
LIST_TOOL,
|
LIST_TOOL,
|
||||||
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
|
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 workspace_id = encode_path_segment(self.workspace_id()?);
|
||||||
let alias_path = encode_path_segment(alias);
|
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 {
|
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
|
||||||
method: WorkspaceRequestMethod::Delete,
|
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,
|
body: None,
|
||||||
})?;
|
})?;
|
||||||
workdir_output(
|
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>>(
|
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
WorkdirOperation::Detach => {
|
WorkdirOperation::Detach => {
|
||||||
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
|
|
||||||
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||||
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
|
self.backend.detach_managed(&input.alias, None).await
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkdirOperation::Delete => self
|
WorkdirOperation::Delete => self
|
||||||
.backend
|
.backend
|
||||||
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
|
.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>>(
|
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||||
response: WorkspaceResponse,
|
response: WorkspaceResponse,
|
||||||
) -> Result<T, ToolError> {
|
) -> 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() {
|
if !response.is_success() {
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
"Workspace Workdir API returned HTTP {}: {}",
|
"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
|
//! authority. In that case model-visible Memory tools must go through the
|
||||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
//! 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 agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
@@ -23,11 +25,15 @@ use crate::feature::{
|
|||||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
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::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||||
use crate::worker::{
|
use crate::worker::{
|
||||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -73,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
|||||||
Decode(#[from] serde_json::Error),
|
Decode(#[from] serde_json::Error),
|
||||||
#[error("workspace memory backend rejected operation: {0}")]
|
#[error("workspace memory backend rejected operation: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
|
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||||
|
InvalidResponse(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl dyn WorkspaceClient + '_ {
|
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(
|
async fn execute_subjektiv_memory_operation(
|
||||||
client: &dyn WorkspaceClient,
|
client: &dyn WorkspaceClient,
|
||||||
operation: server_api::SubjektivMemoryBackendOperation,
|
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> {
|
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
|
||||||
let workspace_id =
|
let workspace_id =
|
||||||
client
|
client
|
||||||
@@ -163,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
|
|||||||
client.kind()
|
client.kind()
|
||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
let response = client.execute(WorkspaceRequest::json(
|
let response = execute(
|
||||||
WorkspaceRequestMethod::Post,
|
client,
|
||||||
format!("/api/w/{workspace_id}/subjektiv/memory"),
|
WorkspaceRequest::json(
|
||||||
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
|
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)
|
let status = reqwest::StatusCode::from_u16(response.status)
|
||||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
if !response.is_success() {
|
if !response.is_success() {
|
||||||
@@ -653,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
|||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkspaceSubjektivResidentSummarySource {
|
#[derive(Default)]
|
||||||
client: Arc<dyn WorkspaceClient>,
|
struct SubjectBehaviorRefreshState {
|
||||||
|
applied: Option<(u64, String)>,
|
||||||
|
pending: HashMap<u64, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
struct WorkspaceSubjektivResidentSummarySource {
|
||||||
"The current ready resident memory surface is intentionally empty.";
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
|
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||||
|
}
|
||||||
|
|
||||||
fn resident_summary_contribution(
|
fn resident_summary_contribution(
|
||||||
output: memory::backend::MemoryResidentSummaryOutput,
|
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]
|
#[async_trait]
|
||||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||||
async fn load(&self) -> SystemPromptContribution {
|
async fn load(&self) -> SystemPromptContribution {
|
||||||
match execute_subjektiv_memory_operation(
|
match self.fetch().await {
|
||||||
self.client.as_ref(),
|
Ok(output) => match self.render(&output) {
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
Ok(rendered) => {
|
||||||
)
|
self.remember_behavior(&output);
|
||||||
.await
|
SystemPromptContribution::Ready(rendered)
|
||||||
{
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
Err(error) => {
|
||||||
Ok(other) => {
|
tracing::error!(%error, "subject resident context render failed");
|
||||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
SystemPromptContribution::Ready(
|
||||||
SystemPromptContribution::Unavailable
|
"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) => {
|
Err(error) => {
|
||||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
tracing::error!(%error, "subject resident context fetch failed");
|
||||||
SystemPromptContribution::Unavailable
|
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_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
|
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||||
@@ -773,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
|||||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||||
FeatureHookPoint::WorkerRestored,
|
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> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
@@ -784,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
|||||||
prompts: Arc::clone(&self.prompts),
|
prompts: Arc::clone(&self.prompts),
|
||||||
workspace_id: self.workspace_id.clone(),
|
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 {
|
struct SubjektivResidentRestoreRefreshHook {
|
||||||
source: Arc<dyn SystemPromptContributionSource>,
|
source: Arc<dyn SystemPromptContributionSource>,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
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(
|
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||||
manifest: &manifest::WorkerManifest,
|
manifest: &manifest::WorkerManifest,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -968,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
|||||||
}
|
}
|
||||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||||
client,
|
client,
|
||||||
|
prompts,
|
||||||
|
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,6 +1293,10 @@ mod tests {
|
|||||||
use agen::tool::ToolDefinition;
|
use agen::tool::ToolDefinition;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
|
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -1112,7 +1309,9 @@ mod tests {
|
|||||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
scope_allowed: bool,
|
scope_allowed: bool,
|
||||||
|
behavior: Mutex<(u64, String)>,
|
||||||
paths: Mutex<Vec<String>>,
|
paths: Mutex<Vec<String>>,
|
||||||
|
timeouts: Mutex<Vec<Duration>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubjectResidentClient {
|
impl SubjectResidentClient {
|
||||||
@@ -1124,7 +1323,9 @@ mod tests {
|
|||||||
availability,
|
availability,
|
||||||
content: content.map(str::to_string),
|
content: content.map(str::to_string),
|
||||||
scope_allowed: true,
|
scope_allowed: true,
|
||||||
|
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
paths: Mutex::new(Vec::new()),
|
||||||
|
timeouts: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1334,9 @@ mod tests {
|
|||||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
content: Some("must not be injected".into()),
|
content: Some("must not be injected".into()),
|
||||||
scope_allowed: false,
|
scope_allowed: false,
|
||||||
|
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||||
paths: Mutex::new(Vec::new()),
|
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();
|
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
request.operation,
|
request.operation,
|
||||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||||
));
|
));
|
||||||
if !self.scope_allowed {
|
if !self.scope_allowed {
|
||||||
return Ok(WorkspaceResponse {
|
return Ok(WorkspaceResponse {
|
||||||
@@ -1169,19 +1372,33 @@ mod tests {
|
|||||||
body: "subject scope unavailable".into(),
|
body: "subject scope unavailable".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||||
Ok(WorkspaceResponse {
|
Ok(WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: serde_json::to_string(
|
body: serde_json::to_string(
|
||||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||||
memory::backend::MemoryResidentSummaryOutput {
|
server_api::SubjektivResidentContextOutput {
|
||||||
availability: self.availability,
|
behavior_md,
|
||||||
content: self.content.clone(),
|
behavior_revision,
|
||||||
|
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||||
|
availability: self.availability,
|
||||||
|
content: self.content.clone(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.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> {
|
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||||
@@ -1479,29 +1696,44 @@ permission = "write"
|
|||||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
Some("current subject surface"),
|
Some("current subject surface"),
|
||||||
));
|
));
|
||||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
let ready = ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&manifest,
|
||||||
.unwrap();
|
ready_client.clone(),
|
||||||
assert_eq!(
|
test_prompts(),
|
||||||
ready.load().await,
|
)
|
||||||
SystemPromptContribution::Ready("current subject surface".into())
|
.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!(
|
assert_eq!(
|
||||||
ready_client.paths.lock().unwrap().clone(),
|
ready_client.paths.lock().unwrap().clone(),
|
||||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
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(
|
let ready_empty_client = Arc::new(SubjectResidentClient::new(
|
||||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&manifest,
|
||||||
.unwrap();
|
ready_empty_client,
|
||||||
assert_eq!(
|
test_prompts(),
|
||||||
ready_empty.load().await,
|
)
|
||||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
.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 [
|
for availability in [
|
||||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||||
@@ -1514,22 +1746,107 @@ permission = "write"
|
|||||||
availability,
|
availability,
|
||||||
Some("must not be injected"),
|
Some("must not be injected"),
|
||||||
)),
|
)),
|
||||||
|
test_prompts(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.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]
|
#[tokio::test]
|
||||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let client = Arc::new(SubjectResidentClient::denied());
|
let client = Arc::new(SubjectResidentClient::denied());
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
client.paths.lock().unwrap().clone(),
|
client.paths.lock().unwrap().clone(),
|
||||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||||
@@ -1557,7 +1874,7 @@ permission = "write"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
@@ -1569,22 +1886,30 @@ permission = "write"
|
|||||||
Some("policy-only surface must not load"),
|
Some("policy-only surface must not load"),
|
||||||
));
|
));
|
||||||
assert!(
|
assert!(
|
||||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
ordinary_subjektiv_resident_summary_source(
|
||||||
.unwrap()
|
&policy_only,
|
||||||
.is_none()
|
unattached_client.clone(),
|
||||||
|
test_prompts()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||||
|
|
||||||
let foreign = subject_manifest("other-workspace");
|
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]
|
#[test]
|
||||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
let prompts = Arc::new(ArcSwap::from(
|
let prompts = Arc::new(ArcSwap::from(
|
||||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||||
));
|
));
|
||||||
@@ -1598,9 +1923,10 @@ permission = "write"
|
|||||||
#[test]
|
#[test]
|
||||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||||
let manifest = subject_manifest("workspace");
|
let manifest = subject_manifest("workspace");
|
||||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
let source =
|
||||||
.unwrap()
|
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||||
source,
|
source,
|
||||||
Arc::new(ArcSwap::from(
|
Arc::new(ArcSwap::from(
|
||||||
@@ -1611,8 +1937,13 @@ permission = "write"
|
|||||||
let descriptor = feature.descriptor();
|
let descriptor = feature.descriptor();
|
||||||
|
|
||||||
assert!(descriptor.tools.is_empty());
|
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[0].point, FeatureHookPoint::WorkerRestored);
|
||||||
|
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||||
|
assert_eq!(
|
||||||
|
descriptor.hooks[2].point,
|
||||||
|
FeatureHookPoint::BeforeSessionRewrite
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ use crate::feature::{
|
|||||||
};
|
};
|
||||||
use crate::permission::permission_action_for;
|
use crate::permission::permission_action_for;
|
||||||
use crate::wip::{
|
use crate::wip::{
|
||||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
|
|||||||
let (meta, tool) = definition(client.clone(), kind)();
|
let (meta, tool) = definition(client.clone(), kind)();
|
||||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||||
WipMountError::InvalidProjection {
|
WipMountError::InvalidProjection {
|
||||||
route: "/features/merge-request/merge-requests".into(),
|
route: "/merge-requests".into(),
|
||||||
message: format!("Merge Request tool schema cannot be retained: {error}"),
|
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>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
config: MergeRequestFeatureConfig,
|
config: MergeRequestFeatureConfig,
|
||||||
permissions: Option<ToolPermissionConfig>,
|
permissions: Option<ToolPermissionConfig>,
|
||||||
feature_route: &WipFeatureRoute,
|
namespace_route: &WipNamespaceRoute,
|
||||||
) -> Result<(), WipMountError> {
|
) -> 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 tools = native_merge_request_tools(client, config)?;
|
||||||
let claimed_tools = tools
|
let claimed_tools = tools
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
|
|||||||
.get("properties")
|
.get("properties")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||||
route: "/features/merge-request/merge-requests".into(),
|
route: "/merge-requests".into(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"Merge Request tool `{}` schema has no properties",
|
"Merge Request tool `{}` schema has no properties",
|
||||||
tool.name
|
tool.name
|
||||||
@@ -1474,7 +1474,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
|
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
|
||||||
let mut registry = WipMountRegistry::new();
|
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(
|
mount_workspace_http_merge_request_wip(
|
||||||
&mut registry,
|
&mut registry,
|
||||||
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
|
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
|
||||||
@@ -1484,17 +1486,14 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
|
||||||
registry.routes().collect::<Vec<_>>(),
|
|
||||||
["/features/merge-request/merge-requests"]
|
|
||||||
);
|
|
||||||
let resolver = MergeRequestItemResolver {
|
let resolver = MergeRequestItemResolver {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/merge-request/merge-requests".into(),
|
collection_route: "/merge-requests".into(),
|
||||||
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
|
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1645,7 +1644,7 @@ mod tests {
|
|||||||
let resolver = MergeRequestItemResolver {
|
let resolver = MergeRequestItemResolver {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/merge-request/merge-requests".into(),
|
collection_route: "/merge-requests".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let first = resolver.resolve("MR-1").unwrap().object.validator;
|
let first = resolver.resolve("MR-1").unwrap().object.validator;
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use wip_protocol::{
|
|||||||
|
|
||||||
use crate::permission::permission_action_for;
|
use crate::permission::permission_action_for;
|
||||||
use crate::wip::{
|
use crate::wip::{
|
||||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
|
|
||||||
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
|
|||||||
registry: &mut WipMountRegistry,
|
registry: &mut WipMountRegistry,
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
permissions: Option<ToolPermissionConfig>,
|
permissions: Option<ToolPermissionConfig>,
|
||||||
feature_route: &WipFeatureRoute,
|
namespace_route: &WipNamespaceRoute,
|
||||||
) -> Result<(), WipMountError> {
|
) -> Result<(), WipMountError> {
|
||||||
let collection_route = feature_route.child("objectives")?;
|
let collection_route = namespace_route.root().to_string();
|
||||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||||
let collection_descriptor = objective_collection_descriptor();
|
let collection_descriptor = objective_collection_descriptor();
|
||||||
@@ -1775,7 +1775,9 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let mut registry = WipMountRegistry::new();
|
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(
|
mount_workspace_http_objective_wip(
|
||||||
&mut registry,
|
&mut registry,
|
||||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
@@ -1783,18 +1785,16 @@ mod tests {
|
|||||||
"http://backend",
|
"http://backend",
|
||||||
)),
|
)),
|
||||||
None,
|
None,
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
|
||||||
registry.routes().collect::<Vec<_>>(),
|
assert_eq!(namespace_route.root(), "/objectives");
|
||||||
["/features/objective/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]
|
#[test]
|
||||||
@@ -1805,7 +1805,7 @@ mod tests {
|
|||||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||||
)),
|
)),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/objectives".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let initial = resolver.resolve("O-3").unwrap();
|
let initial = resolver.resolve("O-3").unwrap();
|
||||||
@@ -1971,7 +1971,7 @@ mod tests {
|
|||||||
let collection = ObjectiveCollectionWipHandler {
|
let collection = ObjectiveCollectionWipHandler {
|
||||||
backend,
|
backend,
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/objectives".into(),
|
||||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
};
|
};
|
||||||
let result = collection
|
let result = collection
|
||||||
@@ -2013,7 +2013,7 @@ mod tests {
|
|||||||
let collection = ObjectiveCollectionWipHandler {
|
let collection = ObjectiveCollectionWipHandler {
|
||||||
backend: backend.clone(),
|
backend: backend.clone(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/objectives".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let context = || WipCallContext {
|
let context = || WipCallContext {
|
||||||
@@ -2170,7 +2170,7 @@ mod tests {
|
|||||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||||
)),
|
)),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/objectives".into(),
|
||||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
};
|
};
|
||||||
let output = match handler
|
let output = match handler
|
||||||
@@ -2191,10 +2191,7 @@ mod tests {
|
|||||||
let output = crate::wip::wip_to_json(&output.value).unwrap();
|
let output = crate::wip::wip_to_json(&output.value).unwrap();
|
||||||
assert_eq!(output["next_cursor"], "next-page");
|
assert_eq!(output["next_cursor"], "next-page");
|
||||||
assert_eq!(output["has_more"], true);
|
assert_eq!(output["has_more"], true);
|
||||||
assert_eq!(
|
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
|
||||||
output["objectives"][0]["path"],
|
|
||||||
"/features/objective/objectives/O-3"
|
|
||||||
);
|
|
||||||
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
|
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
|
||||||
assert!(!output.to_string().contains("00001OBJECTIVE"));
|
assert!(!output.to_string().contains("00001OBJECTIVE"));
|
||||||
assert!(!output.to_string().contains("00001TICKET"));
|
assert!(!output.to_string().contains("00001TICKET"));
|
||||||
@@ -2243,7 +2240,7 @@ mod tests {
|
|||||||
let resolver = ObjectiveItemResolver {
|
let resolver = ObjectiveItemResolver {
|
||||||
backend: backend.clone(),
|
backend: backend.clone(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/objectives".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let before = resolver.resolve("O-3").unwrap().object.validator;
|
let before = resolver.resolve("O-3").unwrap().object.validator;
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ use crate::feature::{
|
|||||||
};
|
};
|
||||||
use crate::permission::permission_action_for;
|
use crate::permission::permission_action_for;
|
||||||
use crate::wip::{
|
use crate::wip::{
|
||||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
|
|||||||
let (meta, tool) = definition();
|
let (meta, tool) = definition();
|
||||||
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
|
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
|
||||||
WipMountError::InvalidProjection {
|
WipMountError::InvalidProjection {
|
||||||
route: "/features/ticket/tickets".into(),
|
route: "/tickets".into(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"enabled Ticket tool `{}` has no native projection",
|
"enabled Ticket tool `{}` has no native projection",
|
||||||
meta.name
|
meta.name
|
||||||
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
|
|||||||
})?;
|
})?;
|
||||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||||
WipMountError::InvalidProjection {
|
WipMountError::InvalidProjection {
|
||||||
route: "/features/ticket/tickets".into(),
|
route: "/tickets".into(),
|
||||||
message: format!("Ticket tool schema cannot be retained: {error}"),
|
message: format!("Ticket tool schema cannot be retained: {error}"),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
|
|||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
access: TicketFeatureAccess,
|
access: TicketFeatureAccess,
|
||||||
permissions: Option<ToolPermissionConfig>,
|
permissions: Option<ToolPermissionConfig>,
|
||||||
feature_route: &WipFeatureRoute,
|
namespace_route: &WipNamespaceRoute,
|
||||||
) -> Result<(), WipMountError> {
|
) -> 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 tools = native_ticket_tools(client, access)?;
|
||||||
let claimed_tools = tools
|
let claimed_tools = tools
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
|
|||||||
.get("properties")
|
.get("properties")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||||
route: "/features/ticket/tickets".into(),
|
route: "/tickets".into(),
|
||||||
message: format!("Ticket tool `{}` schema has no properties", tool.name),
|
message: format!("Ticket tool `{}` schema has no properties", tool.name),
|
||||||
})?;
|
})?;
|
||||||
let required = tool
|
let required = tool
|
||||||
@@ -2103,7 +2103,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
|
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
|
||||||
let mut registry = WipMountRegistry::new();
|
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(
|
mount_workspace_http_ticket_wip(
|
||||||
&mut registry,
|
&mut registry,
|
||||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||||
@@ -2112,20 +2112,21 @@ mod tests {
|
|||||||
)),
|
)),
|
||||||
TicketFeatureAccess::review(),
|
TicketFeatureAccess::review(),
|
||||||
None,
|
None,
|
||||||
&feature_route,
|
&namespace_route,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
|
||||||
registry.routes().collect::<Vec<_>>(),
|
assert!(
|
||||||
["/features/ticket/tickets"]
|
registry
|
||||||
|
.allocate_namespace("ticket", "../objectives")
|
||||||
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(feature_route.child("../objectives").is_err());
|
|
||||||
|
|
||||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||||
let resolver = TicketItemResolver {
|
let resolver = TicketItemResolver {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/ticket/tickets".into(),
|
collection_route: "/tickets".into(),
|
||||||
revisions,
|
revisions,
|
||||||
};
|
};
|
||||||
assert!(resolver.resolve("T-42").is_some());
|
assert!(resolver.resolve("T-42").is_some());
|
||||||
@@ -2189,7 +2190,7 @@ mod tests {
|
|||||||
let resolver = TicketItemResolver {
|
let resolver = TicketItemResolver {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/ticket/tickets".into(),
|
collection_route: "/tickets".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
|
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
|
||||||
@@ -2243,7 +2244,7 @@ mod tests {
|
|||||||
let resolver = TicketItemResolver {
|
let resolver = TicketItemResolver {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/ticket/tickets".into(),
|
collection_route: "/tickets".into(),
|
||||||
revisions: Arc::clone(&revisions),
|
revisions: Arc::clone(&revisions),
|
||||||
};
|
};
|
||||||
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
|
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
|
||||||
|
|||||||
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
|
|||||||
.expect("system-item append queue poisoned")
|
.expect("system-item append queue poisoned")
|
||||||
.push(item);
|
.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
|
/// 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::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::session_history::SessionHistoryMetadata;
|
use crate::session_history::SessionHistoryMetadata;
|
||||||
use crate::worker::SystemItemCommitter;
|
use crate::worker::{
|
||||||
|
DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource,
|
||||||
|
};
|
||||||
use agen::HistoryEntry;
|
use agen::HistoryEntry;
|
||||||
use agen::token_counter::total_tokens;
|
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
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
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>>>>,
|
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||||
/// Next turn index assigned by `on_prompt_submit`.
|
/// Next turn index assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
@@ -163,12 +167,22 @@ impl WorkerInterceptor {
|
|||||||
prompts,
|
prompts,
|
||||||
prompt_workspace_id: None,
|
prompt_workspace_id: None,
|
||||||
log_writer,
|
log_writer,
|
||||||
|
notification_committer: None,
|
||||||
|
resident_context_source: None,
|
||||||
pending_committed_history,
|
pending_committed_history,
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: 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 {
|
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
|
||||||
self.usage_tracker = Some(usage_tracker);
|
self.usage_tracker = Some(usage_tracker);
|
||||||
self
|
self
|
||||||
@@ -179,11 +193,31 @@ impl WorkerInterceptor {
|
|||||||
self
|
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 {
|
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||||
self.prompt_workspace_id = workspace_id;
|
self.prompt_workspace_id = workspace_id;
|
||||||
self
|
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`
|
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||||
/// entry through the attached writer (no-op when no writer is
|
/// entry through the attached writer (no-op when no writer is
|
||||||
/// wired). Sync — writes complete before the matching
|
/// wired). Sync — writes complete before the matching
|
||||||
@@ -211,6 +245,7 @@ impl WorkerInterceptor {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("pending committed history poisoned")
|
.expect("pending committed history poisoned")
|
||||||
.push_back(entry);
|
.push_back(entry);
|
||||||
|
self.confirm_resident_context_item(item);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -350,6 +385,11 @@ impl WorkerInterceptor {
|
|||||||
} if prompt_provenance.is_none() => {
|
} if prompt_provenance.is_none() => {
|
||||||
*prompt_provenance = Some(provenance("internal.task_reminder"));
|
*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 {
|
SystemItem::Interrupt {
|
||||||
prompt_provenance, ..
|
prompt_provenance, ..
|
||||||
} if prompt_provenance.is_none() => {
|
} if prompt_provenance.is_none() => {
|
||||||
@@ -452,19 +492,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
projection_digest: projection.catalog_digest.clone(),
|
projection_digest: projection.catalog_digest.clone(),
|
||||||
logical_name: "internal.notify_wrapper".to_string(),
|
logical_name: "internal.notify_wrapper".to_string(),
|
||||||
};
|
};
|
||||||
let mut system_items: Vec<(
|
let mut system_items = Vec::with_capacity(drained.len());
|
||||||
SystemItem,
|
|
||||||
Vec<session_store::SessionExtension>,
|
|
||||||
Option<session_store::LoggedSessionHistoryOrigin>,
|
|
||||||
)> = Vec::with_capacity(drained.len());
|
|
||||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
|
||||||
for entry in &drained {
|
for entry in &drained {
|
||||||
let system_item = match build_system_item_with_provenance(
|
match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) {
|
||||||
entry,
|
Ok(item) => system_items.push(item),
|
||||||
&prompts,
|
|
||||||
Some(provenance.clone()),
|
|
||||||
) {
|
|
||||||
Ok(system_item) => system_item,
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.pending_notifies.requeue_front(drained);
|
self.pending_notifies.requeue_front(drained);
|
||||||
return Err(InterceptorError::new(
|
return Err(InterceptorError::new(
|
||||||
@@ -472,16 +503,50 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
format!("failed to render notify_wrapper: {error}"),
|
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) {
|
let mut items = Vec::with_capacity(drained.len());
|
||||||
self.pending_notifies.requeue_front(drained);
|
let mut remaining = drained.into_iter();
|
||||||
return Err(InterceptorError::new(
|
for item in system_items {
|
||||||
InterceptorErrorCategory::Dependency,
|
let entry = remaining.next().expect("rendered notification must exist");
|
||||||
format!("session persistence failed: {error}"),
|
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)
|
Ok(items)
|
||||||
}
|
}
|
||||||
@@ -781,6 +846,7 @@ mod tests {
|
|||||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||||
|
use crate::worker::SystemPromptContribution;
|
||||||
|
|
||||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
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(
|
fn task_tool_call_info(
|
||||||
name: &str,
|
name: &str,
|
||||||
input: serde_json::Value,
|
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]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::worker::NotificationCommitIdentity;
|
||||||
use protocol::WorkerEvent;
|
use protocol::WorkerEvent;
|
||||||
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
|
use session_store::{LoggedSessionHistoryOrigin, SystemItem};
|
||||||
|
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
|||||||
pub enum PendingNotify {
|
pub enum PendingNotify {
|
||||||
Notify {
|
Notify {
|
||||||
message: String,
|
message: String,
|
||||||
extensions: Vec<SessionExtension>,
|
notification_commit: Option<NotificationCommitIdentity>,
|
||||||
history_provenance: Option<LoggedSessionHistoryOrigin>,
|
history_provenance: Option<LoggedSessionHistoryOrigin>,
|
||||||
},
|
},
|
||||||
WorkerEvent {
|
WorkerEvent {
|
||||||
@@ -48,10 +49,13 @@ pub enum PendingNotify {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PendingNotify {
|
impl PendingNotify {
|
||||||
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
|
pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> {
|
||||||
match self {
|
match self {
|
||||||
PendingNotify::Notify { extensions, .. } => extensions.clone(),
|
PendingNotify::Notify {
|
||||||
PendingNotify::WorkerEvent { .. } => Vec::new(),
|
notification_commit,
|
||||||
|
..
|
||||||
|
} => notification_commit.as_ref(),
|
||||||
|
PendingNotify::WorkerEvent { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +89,7 @@ impl NotifyBuffer {
|
|||||||
pub fn push_notify(&self, message: String) {
|
pub fn push_notify(&self, message: String) {
|
||||||
self.push_entry(PendingNotify::Notify {
|
self.push_entry(PendingNotify::Notify {
|
||||||
message,
|
message,
|
||||||
extensions: Vec::new(),
|
notification_commit: None,
|
||||||
history_provenance: None,
|
history_provenance: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -94,11 +98,11 @@ impl NotifyBuffer {
|
|||||||
&self,
|
&self,
|
||||||
message: String,
|
message: String,
|
||||||
history_provenance: LoggedSessionHistoryOrigin,
|
history_provenance: LoggedSessionHistoryOrigin,
|
||||||
extension: SessionExtension,
|
identity: NotificationCommitIdentity,
|
||||||
) {
|
) {
|
||||||
self.push_entry(PendingNotify::Notify {
|
self.push_entry(PendingNotify::Notify {
|
||||||
message,
|
message,
|
||||||
extensions: vec![extension],
|
notification_commit: Some(identity),
|
||||||
history_provenance: Some(history_provenance),
|
history_provenance: Some(history_provenance),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,7 +237,7 @@ mod tests {
|
|||||||
fn build_system_item_for_notify_carries_wrapper_body() {
|
fn build_system_item_for_notify_carries_wrapper_body() {
|
||||||
let entry = PendingNotify::Notify {
|
let entry = PendingNotify::Notify {
|
||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
extensions: Vec::new(),
|
notification_commit: None,
|
||||||
history_provenance: None,
|
history_provenance: None,
|
||||||
};
|
};
|
||||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
|||||||
}
|
}
|
||||||
entry @ (LogEntry::AnnotatedAssistantItem { .. }
|
entry @ (LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::RunYielded { .. }
|
| LogEntry::RunYielded { .. }
|
||||||
| LogEntry::RunResumed { .. }
|
| LogEntry::RunResumed { .. }
|
||||||
| LogEntry::RunCancelled { .. }
|
| LogEntry::RunCancelled { .. }
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
|
|||||||
AgentsMdSection,
|
AgentsMdSection,
|
||||||
ResidentMemorySummarySection,
|
ResidentMemorySummarySection,
|
||||||
ResidentMemoryRestoreSection,
|
ResidentMemoryRestoreSection,
|
||||||
|
SubjektivResidentContext,
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
SubWorkerSpawnToolDescription,
|
SubWorkerSpawnToolDescription,
|
||||||
}
|
}
|
||||||
@@ -128,6 +129,7 @@ impl WorkerPrompt {
|
|||||||
Self::AgentsMdSection => "internal.agents_md_section",
|
Self::AgentsMdSection => "internal.agents_md_section",
|
||||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||||
|
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
|
||||||
Self::WorkerOrchestrationGuidanceSection => {
|
Self::WorkerOrchestrationGuidanceSection => {
|
||||||
"internal.worker_orchestration_guidance_section"
|
"internal.worker_orchestration_guidance_section"
|
||||||
}
|
}
|
||||||
@@ -150,6 +152,7 @@ impl WorkerPrompt {
|
|||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||||
|
WorkerPrompt::SubjektivResidentContext,
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
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> {
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||||
self.render(
|
self.render(
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl SegmentLogSink {
|
|||||||
| LogEntry::AnnotatedUserInput { .. }
|
| LogEntry::AnnotatedUserInput { .. }
|
||||||
| LogEntry::AnnotatedAssistantItem { .. }
|
| LogEntry::AnnotatedAssistantItem { .. }
|
||||||
| LogEntry::AnnotatedToolResult { .. }
|
| LogEntry::AnnotatedToolResult { .. }
|
||||||
|
| LogEntry::ToolResultCorrected { .. }
|
||||||
| LogEntry::AnnotatedSystemItem { .. }
|
| LogEntry::AnnotatedSystemItem { .. }
|
||||||
| LogEntry::Invoke { .. }
|
| LogEntry::Invoke { .. }
|
||||||
| LogEntry::RunYielded { .. }
|
| LogEntry::RunYielded { .. }
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
|
|||||||
}
|
}
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(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(
|
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||||
entry.item.to_history_item(),
|
entry.item.to_history_item(),
|
||||||
entry.metadata.clone(),
|
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};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
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 SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
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";
|
"preserve explicit observe grants for committed Worker Session archives";
|
||||||
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
||||||
"durable terminal Backend Job Worker cleanup";
|
"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_VERSION_WITH_TARGETS: i64 = 7;
|
||||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||||
@@ -259,6 +260,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
||||||
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -945,6 +951,23 @@ pub struct WorkdirRegistryRecord {
|
|||||||
pub updated_at: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ExternalWorkdirGrantRecord {
|
pub struct ExternalWorkdirGrantRecord {
|
||||||
pub grant_id: String,
|
pub grant_id: String,
|
||||||
@@ -963,6 +986,8 @@ pub struct ExternalWorkdirGrantRecord {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerWorkdirLinkRecord {
|
pub struct WorkerWorkdirLinkRecord {
|
||||||
|
/// Assigned by the ledger on attach; preserved only by explicit compensation.
|
||||||
|
pub connection_id: String,
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
pub workdir_id: String,
|
pub workdir_id: String,
|
||||||
@@ -1888,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<WorkdirRegistryRecord>>;
|
) -> 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 delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||||
|
|
||||||
fn create_external_workdir_grant(
|
fn create_external_workdir_grant(
|
||||||
@@ -1983,6 +2014,31 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
expected_workdir_id: Option<&str>,
|
expected_workdir_id: Option<&str>,
|
||||||
unlinked_at: &str,
|
unlinked_at: &str,
|
||||||
) -> Result<Option<WorkerWorkdirLinkRecord>>;
|
) -> 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(
|
fn worker_workdir_link_history_exists(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
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> {
|
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
@@ -9349,6 +9471,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
"reserved attachment finalization requires an active attachment".to_string(),
|
"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| {
|
self.with_conn(|conn| {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
conn,
|
conn,
|
||||||
@@ -9370,11 +9494,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"INSERT INTO worker_workdir_links (
|
r#"INSERT INTO worker_workdir_links (
|
||||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
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)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
|
||||||
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
||||||
capabilities = excluded.capabilities,
|
capabilities = excluded.capabilities,
|
||||||
linked_at = excluded.linked_at,
|
linked_at = excluded.linked_at,
|
||||||
|
connection_id = excluded.connection_id,
|
||||||
unlinked_at = NULL"#,
|
unlinked_at = NULL"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
@@ -9384,6 +9509,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.alias,
|
record.alias,
|
||||||
encode_workdir_link_capabilities(record.capabilities)?,
|
encode_workdir_link_capabilities(record.capabilities)?,
|
||||||
record.linked_at,
|
record.linked_at,
|
||||||
|
record.connection_id,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -9421,6 +9547,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
"a new attachment cannot already be unlinked".to_string(),
|
"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| {
|
self.with_conn(|conn| {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
conn,
|
conn,
|
||||||
@@ -9456,7 +9584,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let active_for_alias = tx
|
let active_for_alias = tx
|
||||||
.query_row(
|
.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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
AND alias = ?4 AND unlinked_at IS NULL"#,
|
AND alias = ?4 AND unlinked_at IS NULL"#,
|
||||||
@@ -9499,7 +9627,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let active_for_workdir = tx
|
let active_for_workdir = tx
|
||||||
.query_row(
|
.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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
|
||||||
params![record.workspace_id, record.workdir_id],
|
params![record.workspace_id, record.workdir_id],
|
||||||
@@ -9514,11 +9642,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let write = tx.execute(
|
let write = tx.execute(
|
||||||
r#"INSERT INTO worker_workdir_links (
|
r#"INSERT INTO worker_workdir_links (
|
||||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
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)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
|
||||||
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
||||||
capabilities = excluded.capabilities,
|
capabilities = excluded.capabilities,
|
||||||
linked_at = excluded.linked_at,
|
linked_at = excluded.linked_at,
|
||||||
|
connection_id = excluded.connection_id,
|
||||||
unlinked_at = NULL"#,
|
unlinked_at = NULL"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
@@ -9528,6 +9657,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.alias,
|
record.alias,
|
||||||
encode_workdir_link_capabilities(record.capabilities)?,
|
encode_workdir_link_capabilities(record.capabilities)?,
|
||||||
record.linked_at,
|
record.linked_at,
|
||||||
|
record.connection_id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if let Err(error) = write {
|
if let Err(error) = write {
|
||||||
@@ -9556,7 +9686,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
rusqlite::TransactionBehavior::Immediate,
|
rusqlite::TransactionBehavior::Immediate,
|
||||||
)?;
|
)?;
|
||||||
let mut stmt = tx.prepare(
|
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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
AND unlinked_at IS NULL"#,
|
AND unlinked_at IS NULL"#,
|
||||||
@@ -9571,7 +9701,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
|
|
||||||
let mut persisted_identity = persisted
|
let mut persisted_identity = persisted
|
||||||
.iter()
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
persisted_identity.sort_unstable();
|
persisted_identity.sort_unstable();
|
||||||
let mut requested_identity = active_links
|
let mut requested_identity = active_links
|
||||||
@@ -9592,7 +9722,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
link.alias
|
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<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
requested_identity.sort_unstable();
|
requested_identity.sort_unstable();
|
||||||
@@ -9644,7 +9774,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
)?;
|
)?;
|
||||||
let active = tx
|
let active = tx
|
||||||
.query_row(
|
.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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
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"#,
|
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(
|
fn worker_workdir_link_history_exists(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -9708,7 +10034,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
@@ -9729,7 +10055,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
@@ -9750,7 +10076,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Option<WorkerWorkdirLinkRecord>> {
|
) -> Result<Option<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.query_row(
|
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
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2
|
WHERE workspace_id = ?1 AND workdir_id = ?2
|
||||||
ORDER BY linked_at DESC, rowid DESC
|
ORDER BY linked_at DESC, rowid DESC
|
||||||
@@ -10497,6 +10823,7 @@ fn read_worker_workdir_link_record(
|
|||||||
capabilities,
|
capabilities,
|
||||||
linked_at: row.get(6)?,
|
linked_at: row.get(6)?,
|
||||||
unlinked_at: row.get(7)?,
|
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 {
|
fn workdir_registry_select_sql(where_clause: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \
|
"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(())
|
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<()> {
|
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
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
|
CREATE INDEX typed_ticket_targets_workspace_repository
|
||||||
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
|
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
|
ALTER TABLE worker_workdir_links
|
||||||
ADD COLUMN capabilities TEXT NOT NULL
|
ADD COLUMN capabilities TEXT NOT NULL
|
||||||
CHECK (capabilities IN (
|
CHECK (capabilities IN (
|
||||||
@@ -15610,6 +16049,46 @@ mod tests {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
||||||
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
||||||
@@ -17052,6 +17531,10 @@ mod tests {
|
|||||||
version: 77,
|
version: 77,
|
||||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
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,
|
77,
|
||||||
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||||
),
|
),
|
||||||
|
(78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -17490,7 +17974,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
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();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -17499,7 +17983,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
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", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").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();
|
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
|
||||||
|
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
worker: worker.worker.clone(),
|
worker: worker.worker.clone(),
|
||||||
workdir_id: workdir.workdir_id.clone(),
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
@@ -20883,12 +21368,10 @@ INSERT INTO worker_registry (
|
|||||||
store.attach_worker_workdir(&link),
|
store.attach_worker_workdir(&link),
|
||||||
Err(Error::WorkdirAttachmentConflict(_))
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
));
|
));
|
||||||
assert_eq!(
|
let link = store
|
||||||
store
|
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
|
||||||
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
|
.unwrap();
|
||||||
.unwrap(),
|
assert!(!link.connection_id.is_empty());
|
||||||
link
|
|
||||||
);
|
|
||||||
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
|
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
|
||||||
let downgraded_link = WorkerWorkdirLinkRecord {
|
let downgraded_link = WorkerWorkdirLinkRecord {
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
@@ -20958,10 +21441,7 @@ INSERT INTO worker_registry (
|
|||||||
store.attach_worker_workdir(&invalid_capabilities),
|
store.attach_worker_workdir(&invalid_capabilities),
|
||||||
Err(Error::InvalidInput(_))
|
Err(Error::InvalidInput(_))
|
||||||
));
|
));
|
||||||
assert_eq!(
|
let second_attachment = store.attach_worker_workdir(&second_attachment).unwrap();
|
||||||
store.attach_worker_workdir(&second_attachment).unwrap(),
|
|
||||||
second_attachment
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.list_worker_workdir_links("local-dev", &worker.worker)
|
.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",),
|
store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",),
|
||||||
Err(Error::WorkdirAttachmentConflict(_))
|
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
|
let detached = store
|
||||||
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
|
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -21001,10 +21632,7 @@ INSERT INTO worker_registry (
|
|||||||
.worker_workdir_link_history_exists("local-dev", &worker.worker)
|
.worker_workdir_link_history_exists("local-dev", &worker.worker)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap();
|
||||||
store.attach_worker_workdir(&workdir_conflict).unwrap(),
|
|
||||||
workdir_conflict
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(store);
|
drop(store);
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::collections::HashSet;
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||||
use rusqlite::{OptionalExtension, Transaction, params};
|
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
|||||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||||
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
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
|
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
/// 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
|
/// payload is capped below the whole-input budget to reserve room for the fixed
|
||||||
@@ -74,6 +76,14 @@ pub enum SubjektivError {
|
|||||||
expected: u64,
|
expected: u64,
|
||||||
actual: 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}")]
|
#[error("surface generation conflict: {0}")]
|
||||||
SurfaceGenerationConflict(String),
|
SurfaceGenerationConflict(String),
|
||||||
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
||||||
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
|
|||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub role: SubjectRole,
|
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,
|
pub state: SubjectState,
|
||||||
/// Monotonic generation incremented exactly once for every committed Memory
|
/// Monotonic generation incremented exactly once for every committed Memory
|
||||||
/// creation or revision for this subject.
|
/// creation or revision for this subject.
|
||||||
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
|
|||||||
pub snapshot: Option<SurfaceSnapshot>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum MemoryRevisionTarget {
|
pub enum MemoryRevisionTarget {
|
||||||
@@ -986,6 +1010,17 @@ END;
|
|||||||
Ok(())
|
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] = &[
|
static MIGRATIONS: &[FeatureMigration] = &[
|
||||||
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||||
FeatureMigration::new(
|
FeatureMigration::new(
|
||||||
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
|||||||
"add bounded surface generation state and runs",
|
"add bounded surface generation state and runs",
|
||||||
add_surface_generation_state,
|
add_surface_generation_state,
|
||||||
),
|
),
|
||||||
|
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REGISTRATION: FeatureRegistration =
|
pub const REGISTRATION: FeatureRegistration =
|
||||||
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
|
|||||||
/// Issues a random host-owned subject id. It is never derived from Worker,
|
/// Issues a random host-owned subject id. It is never derived from Worker,
|
||||||
/// Profile, Session, or Memory content.
|
/// Profile, Session, or Memory content.
|
||||||
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
|
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 timestamp = now();
|
||||||
let record = SubjectRecord {
|
let record = SubjectRecord {
|
||||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
id: issued_id("subject"),
|
id: issued_id("subject"),
|
||||||
role,
|
role,
|
||||||
|
behavior_md,
|
||||||
|
behavior_revision: 0,
|
||||||
state: SubjectState::Active,
|
state: SubjectState::Active,
|
||||||
store_revision: 0,
|
store_revision: 0,
|
||||||
created_at: timestamp.clone(),
|
created_at: timestamp.clone(),
|
||||||
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
|
|||||||
self.database.try_transaction(|transaction| {
|
self.database.try_transaction(|transaction| {
|
||||||
transaction.execute(
|
transaction.execute(
|
||||||
"INSERT INTO subjects (
|
"INSERT INTO subjects (
|
||||||
subject_id, role, state, store_revision, record_json, created_at, updated_at
|
subject_id, role, behavior_md, behavior_revision, state,
|
||||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
store_revision, record_json, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
|
||||||
params![
|
params![
|
||||||
record.id,
|
record.id,
|
||||||
record.role.as_str(),
|
record.role.as_str(),
|
||||||
|
record.behavior_md,
|
||||||
raw,
|
raw,
|
||||||
record.created_at,
|
record.created_at,
|
||||||
record.updated_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
|
/// Validates an explicit product launch connection against this Workspace's
|
||||||
/// subject store and the already-resolved Profile policy. Subject knowledge
|
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||||
/// alone is insufficient: only an active local record produces host launch material.
|
/// 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> {
|
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
|
||||||
self.database.try_with_connection(|connection| {
|
self.database.try_with_connection(|connection| {
|
||||||
let subject = require_subject_in_connection(connection, subject_id)?;
|
let subject = require_subject_in_connection(connection, subject_id)?;
|
||||||
let state = connection
|
resident_surface_in_connection(connection, &subject)
|
||||||
.query_row(
|
})
|
||||||
"SELECT store_revision, status, snapshot_id
|
}
|
||||||
FROM surface_generation_state WHERE subject_id = ?1",
|
|
||||||
[subject_id],
|
/// Atomically reads user-managed behavior and the independently-fresh
|
||||||
|row| {
|
/// generated Memory surface from one SQLite connection snapshot.
|
||||||
Ok((
|
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
|
||||||
row.get::<_, i64>(0)?,
|
self.database.try_transaction(|transaction| {
|
||||||
row.get::<_, String>(1)?,
|
let subject = require_subject(transaction, subject_id)?;
|
||||||
row.get::<_, Option<String>>(2)?,
|
let surface = resident_surface_in_connection(transaction, &subject)?;
|
||||||
))
|
Ok(SubjectResidentContext { subject, surface })
|
||||||
},
|
|
||||||
)
|
|
||||||
.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),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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<()> {
|
fn validate_label(kind: &str, value: &str) -> Result<()> {
|
||||||
validate_nonempty(kind, value)?;
|
validate_nonempty(kind, value)?;
|
||||||
if value.len() > 256 || value.chars().any(char::is_control) {
|
if value.len() > 256 || value.chars().any(char::is_control) {
|
||||||
@@ -3668,6 +3801,67 @@ mod tests {
|
|||||||
assert!(!complete.has_more);
|
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]
|
#[test]
|
||||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
@@ -3892,10 +4086,38 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).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 (_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(
|
let lifecycle_attribution = attribution(
|
||||||
&subject_id,
|
&subject_id,
|
||||||
"runtime-1",
|
"runtime-1",
|
||||||
@@ -3935,26 +4157,50 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).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 {
|
let snapshot = SurfaceSnapshot {
|
||||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
id: "legacy-surface".into(),
|
id: "legacy-surface".into(),
|
||||||
subject_id: subject.id.clone(),
|
subject_id: subject_id.clone(),
|
||||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||||
memory_refs: Vec::new(),
|
memory_refs: Vec::new(),
|
||||||
built_from_store_revision: subject.store_revision,
|
built_from_store_revision: 0,
|
||||||
created_at: now(),
|
created_at: now(),
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.database
|
.database
|
||||||
.try_transaction(|transaction| -> Result<()> {
|
.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(
|
transaction.execute(
|
||||||
"INSERT INTO surface_snapshots (
|
"INSERT INTO surface_snapshots (
|
||||||
subject_id, snapshot_id, built_from_store_revision,
|
subject_id, snapshot_id, built_from_store_revision,
|
||||||
snapshot_json, created_at
|
snapshot_json, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![
|
params![
|
||||||
subject.id,
|
subject_id,
|
||||||
snapshot.id,
|
snapshot.id,
|
||||||
to_i64(snapshot.built_from_store_revision)?,
|
to_i64(snapshot.built_from_store_revision)?,
|
||||||
serde_json::to_string(&snapshot)?,
|
serde_json::to_string(&snapshot)?,
|
||||||
@@ -3964,12 +4210,12 @@ mod tests {
|
|||||||
transaction.execute(
|
transaction.execute(
|
||||||
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
||||||
VALUES (?1, ?2)",
|
VALUES (?1, ?2)",
|
||||||
params![subject.id, snapshot.id],
|
params![subject_id, snapshot.id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(subject.id, snapshot.id)
|
(subject_id, snapshot.id)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||||
|
|||||||
+3
-1
@@ -35,7 +35,9 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
|||||||
27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。
|
27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。
|
||||||
28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts.
|
28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts.
|
||||||
29. [`design/objective-wip-projection.md`](design/objective-wip-projection.md) — Host-assigned Objective collection/item routes, operation mapping, Backend authority, bounds, and compatibility suppression.
|
29. [`design/objective-wip-projection.md`](design/objective-wip-projection.md) — Host-assigned Objective collection/item routes, operation mapping, Backend authority, bounds, and compatibility suppression.
|
||||||
30. [`design/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries.
|
30. [`design/ticket-wip-projection.md`](design/ticket-wip-projection.md) — Host-assigned Ticket collection/item routes, role-specific operations, route identity, and typed Backend preservation.
|
||||||
|
31. [`design/merge-request-wip-projection.md`](design/merge-request-wip-projection.md) — Host-assigned Merge Request routes and preserved review/integration authority gates.
|
||||||
|
32. [`design/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries.
|
||||||
|
|
||||||
## What belongs here
|
## What belongs here
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
When a Worker explicitly runs in `wip` mode and its Merge Request Feature has at least one enabled operation, the Worker Host projects that exact operation set as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Merge Request tools and Backend routes. It does not create a second Merge Request store, provider integration path, review grant, approval rule, or completion authority.
|
When a Worker explicitly runs in `wip` mode and its Merge Request Feature has at least one enabled operation, the Worker Host projects that exact operation set as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Merge Request tools and Backend routes. It does not create a second Merge Request store, provider integration path, review grant, approval rule, or completion authority.
|
||||||
|
|
||||||
The Host allocates `/features/merge-request` and the Feature mounts:
|
The Host assigns the Merge Request provider the root `merge-requests` namespace through `WipMountRegistry` and mounts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/features/merge-request/merge-requests
|
/merge-requests
|
||||||
/features/merge-request/merge-requests/<merge-request-id>
|
/merge-requests/<merge-request-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is no Feature Object or `/features/merge-request` compatibility alias. Legacy paths remain historical display data only and fail as current input with rediscovery guidance.
|
||||||
|
|
||||||
An item identity is a single canonical path segment. Nested paths and percent-encoded target substitution do not resolve. Item operations remove `merge_request_id` from their descriptor and reconstruct it only from the selected object route. Discovery does not mint authority: every call still uses the Worker's scoped `WorkspaceClient`, the enabled Feature flags, ordinary Tool permission rules, and the Backend's Workspace, repository, assignment, provider-ref, review, and integration checks.
|
An item identity is a single canonical path segment. Nested paths and percent-encoded target substitution do not resolve. Item operations remove `merge_request_id` from their descriptor and reconstruct it only from the selected object route. Discovery does not mint authority: every call still uses the Worker's scoped `WorkspaceClient`, the enabled Feature flags, ordinary Tool permission rules, and the Backend's Workspace, repository, assignment, provider-ref, review, and integration checks.
|
||||||
|
|
||||||
## Current Feature inventory
|
## Current Feature inventory
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
When a Worker explicitly runs in `wip` mode and the Objective Feature is enabled, the Worker Host projects Objectives as native Worldspace objects. The projection is an adapter over the existing scoped Workspace API. It does not introduce another Objective store, state machine, reference resolver, or audit path.
|
When a Worker explicitly runs in `wip` mode and the Objective Feature is enabled, the Worker Host projects Objectives as native Worldspace objects. The projection is an adapter over the existing scoped Workspace API. It does not introduce another Objective store, state machine, reference resolver, or audit path.
|
||||||
|
|
||||||
The Host allocates `/features/objective` to the Feature through `WipMountRegistry`. The Objective Feature then mounts:
|
The Host assigns the Objective provider the root `objectives` namespace through `WipMountRegistry` and mounts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/features/objective/objectives
|
/objectives
|
||||||
/features/objective/objectives/<objective-reference>
|
/objectives/<objective-reference>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is no Feature Object or `/features/objective` compatibility alias. Legacy paths are retained only as historical text and fail as current inputs with rediscovery guidance.
|
||||||
|
|
||||||
`<objective-reference>` is either a canonical `O-<digits>` key or an existing canonical internal alphanumeric ID. Only one direct child segment is accepted. Nested suffixes, Ticket keys, and unrelated or hidden prefixes do not resolve as Objective objects.
|
`<objective-reference>` is either a canonical `O-<digits>` key or an existing canonical internal alphanumeric ID. Only one direct child segment is accepted. Nested suffixes, Ticket keys, and unrelated or hidden prefixes do not resolve as Objective objects.
|
||||||
|
|
||||||
The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, revision generation, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`.
|
The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, revision generation, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`.
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
When a Worker explicitly runs in `wip` mode and its Ticket Feature is enabled, the Worker Host projects the enabled Ticket surface as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Ticket API and typed Ticket tools. It does not add a Ticket store, resolver, audit path, state machine, readiness rule, queue path, or relation implementation.
|
When a Worker explicitly runs in `wip` mode and its Ticket Feature is enabled, the Worker Host projects the enabled Ticket surface as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Ticket API and typed Ticket tools. It does not add a Ticket store, resolver, audit path, state machine, readiness rule, queue path, or relation implementation.
|
||||||
|
|
||||||
The Host allocates `/features/ticket` and the Feature mounts:
|
The Host assigns the Ticket provider the root `tickets` namespace through `WipMountRegistry` and mounts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/features/ticket/tickets
|
/tickets
|
||||||
/features/ticket/tickets/<ticket-reference>
|
/tickets/<ticket-reference>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is no Feature Object or `/features/ticket` compatibility alias. A legacy path supplied as current input fails with rediscovery guidance; saved history containing that text is left unchanged.
|
||||||
|
|
||||||
`<ticket-reference>` is either a canonical `T-<digits>` key or an existing canonical internal alphanumeric ID. Only a direct child is accepted. Nested suffixes, Objective keys, unrelated prefixes, and hidden routes do not resolve.
|
`<ticket-reference>` is either a canonical `T-<digits>` key or an existing canonical internal alphanumeric ID. Only a direct child is accepted. Nested suffixes, Objective keys, unrelated prefixes, and hidden routes do not resolve.
|
||||||
|
|
||||||
Every call retains the Worker's scoped `WorkspaceClient`, current Ticket Feature access, and ordinary Tool permission policy. Discovery is not mutation authority. A direct call to an unpublished operation fails even when the caller knows its name. `deny` and interactive `ask` permission outcomes fail closed before Backend dispatch.
|
Every call retains the Worker's scoped `WorkspaceClient`, current Ticket Feature access, and ordinary Tool permission policy. Discovery is not mutation authority. A direct call to an unpublished operation fails even when the caller knows its name. `deny` and interactive `ask` permission outcomes fail closed before Backend dispatch.
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Repository, Workdir and attachment WIP projection
|
||||||
|
|
||||||
|
## Objects and operations
|
||||||
|
|
||||||
|
The Host owns these root namespaces, using the same `WipMountRegistry`
|
||||||
|
namespace/resolver/contribution contracts as Tickets, Objectives and Merge Requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── repositories list
|
||||||
|
│ └── <encoded repository key> read
|
||||||
|
├── workdirs list, create
|
||||||
|
│ └── <encoded Workdir ID> read, attach, delete (when supported)
|
||||||
|
└── workdir-attachments list
|
||||||
|
└── <connection lifetime ID> read, detach
|
||||||
|
```
|
||||||
|
|
||||||
|
Operations are Interface declarations, **not child files**. There is no
|
||||||
|
`/features/manage-workdir`, no writable Repository catalog and no Repository
|
||||||
|
content tree here. `/checkouts` content projection is a separate provider (T-699),
|
||||||
|
as is Workspace configuration (T-695). Management Objects do not grant access to
|
||||||
|
unattached contents. A future content provider should return its entrance from
|
||||||
|
an authorized, live attachment; it must not infer content/command capability
|
||||||
|
from catalog or lifecycle access.
|
||||||
|
|
||||||
|
`path` in inventory results is an absolute WIP Object path. It is a reference,
|
||||||
|
not an OS path, a capability grant, or an instruction to reinterpret another
|
||||||
|
string argument as a route. Source Repository references use `repository_path`;
|
||||||
|
attachment references use `workdir_path`. Attach results include
|
||||||
|
`attachments_path` to discover the current connection and its lifetime ID.
|
||||||
|
|
||||||
|
### Identity and route encoding
|
||||||
|
|
||||||
|
Repository identity is the formal Backend `repository_key`, never its display
|
||||||
|
name or a guessed `main`. Workdir identity is `working_directory_id`, never a
|
||||||
|
Worker-local alias. A segment containing only ASCII letters, digits, `-`, `_`
|
||||||
|
is unchanged. All other UTF-8 identities are encoded as `~` followed by lowercase
|
||||||
|
hexadecimal UTF-8 bytes. The `~` marker is itself always encoded. Only the
|
||||||
|
canonical encoding is accepted; no alternate spellings, recursive URL decoding,
|
||||||
|
case folding, slash traversal or namespace fallback are accepted. For example,
|
||||||
|
`repo/name` is `/repositories/~7265706f2f6e616d65`. This is injective and accepts
|
||||||
|
existing valid keys, including Unicode and slash-containing identities.
|
||||||
|
|
||||||
|
Attachment identity is a fresh opaque Backend connection ID for each lifetime,
|
||||||
|
including detach/reattach to the **same** alias and Workdir. Read exposes alias,
|
||||||
|
Workdir ID and effective attachment capabilities as attributes. Workdir existence
|
||||||
|
and Worker connection are separate Objects. Detaching a connection does not
|
||||||
|
remove the Workdir or delete a logical settings space.
|
||||||
|
|
||||||
|
## Feature and authority separation
|
||||||
|
|
||||||
|
`feature.workdir_catalog.enabled` independently supplies read-only Repository,
|
||||||
|
Workdir and attachment reference operations. It defaults enabled so a WIP Worker
|
||||||
|
with Workspace authority and appropriate permissions can find registered keys
|
||||||
|
without Ticket or conversation hints. It does not add ordinary Tools or require
|
||||||
|
a filesystem/Bash location. Standalone Workers without Workspace authority have
|
||||||
|
no Workspace catalog mounted.
|
||||||
|
|
||||||
|
`feature.manage_workdir.enabled` supplies management operation implementations
|
||||||
|
on the **same Objects** using `contribute_operations` and
|
||||||
|
`contribute_dynamic_operations`. It remains default disabled. It neither owns
|
||||||
|
another resolver nor confers permission. The object provider is unique; duplicate
|
||||||
|
namespace, route, operation name or inconsistent Interface contributions fail at
|
||||||
|
registration through the common registry.
|
||||||
|
|
||||||
|
Native permission identities are:
|
||||||
|
|
||||||
|
| Operation | Manifest permission identity | Bound identity in permission input |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| repositories.list | RepositoryList | list arguments |
|
||||||
|
| repository.read | RepositoryRead | repository_key |
|
||||||
|
| workdirs.list | WorkdirList | list arguments |
|
||||||
|
| workdir.read | WorkdirRead | working_directory_id |
|
||||||
|
| workdirs.create | WorkdirCreate | original create arguments |
|
||||||
|
| workdir.attach | WorkdirAttach | working_directory_id + alias |
|
||||||
|
| workdir.delete | WorkdirDelete | working_directory_id + reason |
|
||||||
|
| attachments.list | WorkdirAttachmentList | list arguments |
|
||||||
|
| attachment.read | WorkdirAttachmentRead | connection_id |
|
||||||
|
| attachment.detach | WorkdirDetach | current bound alias |
|
||||||
|
|
||||||
|
Read permissions gate direct item resolution as well as list item visibility.
|
||||||
|
The manifest's existing default policy is retained; `ask` is denied fail-closed.
|
||||||
|
An argument-dependent policy may advertise an operation that could be allowed,
|
||||||
|
but every call checks its exact reconstructed input before execution. Mutation
|
||||||
|
arguments cannot override path-bound Workdir or connection identity. Having
|
||||||
|
RepositoryRead is not WorkdirCreate; inventory/management permissions are not
|
||||||
|
file access, delegation or command execution grants. Backend caller/Workspace
|
||||||
|
identity, source proof, attachment capability ceilings and lifecycle policy are
|
||||||
|
checked by the existing paths on every request.
|
||||||
|
|
||||||
|
## Contextual Interfaces and stale observations
|
||||||
|
|
||||||
|
Providers opt into common Host contextual Interfaces. Operation publication is
|
||||||
|
the intersection of registered implementation, current target support and current
|
||||||
|
subject policy. The collection reference feature can remain available while the
|
||||||
|
management feature is disabled. External Workdirs without cleanup authority do
|
||||||
|
not expose delete. Only currently supported lifecycle operations are advertised;
|
||||||
|
no delete/command is added to logical attachments solely because they appear as
|
||||||
|
workspaces.
|
||||||
|
|
||||||
|
A contextual Interface reference is opaque and path-qualified as
|
||||||
|
`<base>/@/<hex UTF-8 Object path>`. Clients must use the returned reference, not
|
||||||
|
construct it. Fetch and call freshly resolve the same Object and filtered
|
||||||
|
Interface; base references cannot fetch an unfiltered descriptor. Descriptor and
|
||||||
|
Object validators invalidate old observations after target/connection/permission
|
||||||
|
changes. Validators are managed by the WIP Client, not manually by the LLM.
|
||||||
|
Cached Interface possession and known direct paths never bypass current checks.
|
||||||
|
Historic session text remains append-only and is not rewritten or replayed.
|
||||||
|
|
||||||
|
## Backend lifecycle and safe output
|
||||||
|
|
||||||
|
Catalogs are request-time projections of existing Backend authority, not another
|
||||||
|
store or synchronization job. WIP list pages default to 50 and accept 1–100
|
||||||
|
items. Repository pages sort the existing complete Repository catalog by stable
|
||||||
|
key. Workdir pages use the signed caller-bound Backend
|
||||||
|
`workers/self/workdir-catalog` operation, with SQL keyset paging over the existing
|
||||||
|
Workdir registry in stable ID order. This is not the legacy bounded
|
||||||
|
`working-directories` browser/Tool snapshot: Workdirs older than its 200-record
|
||||||
|
cap remain reachable. `next_cursor` advances over scanned rows even when the
|
||||||
|
current read policy or unsupported legacy source makes a page sparse or empty;
|
||||||
|
`empty` describes that page, and `has_more` remains authoritative for progress.
|
||||||
|
Attachment pages use the Backend's SQL-bounded, alias-ordered limit/offset query
|
||||||
|
and opaque `offset:<n>` cursors. Both Workdir and attachment responses supply an
|
||||||
|
opaque complete-set `revision` derived from existing authoritative records in
|
||||||
|
the same database snapshot as the page, not by hashing the first page. The Host
|
||||||
|
uses this complete-set revision for the collection Object validator. A lifetime
|
||||||
|
or capability change beyond row 50 therefore invalidates old collection
|
||||||
|
observations without adding a ledger or revision store. A cursor is not
|
||||||
|
a snapshot or authority grant: restart listing after concurrent collection
|
||||||
|
changes. Filtering by exact `connection_id` is applied in the caller-scoped
|
||||||
|
Backend ledger before paging, so direct item lookup also works beyond the first
|
||||||
|
page. Unknown/expired IDs yield no item, never a current alias substitute.
|
||||||
|
Repository projection uses an explicit field
|
||||||
|
allowlist: formal key, kind/provider, any existing display metadata and explicit
|
||||||
|
nullable default selector. Source URI/path, credentials, remotes, Runtime
|
||||||
|
connection information and internal fingerprints are not model-visible.
|
||||||
|
Workdir projection includes stable ID, optional display name, public source kind
|
||||||
|
and Repository key, existing selector/ref/tree, status, cleanliness and occupancy
|
||||||
|
when available. Cleanup handles and External grant internals are not forwarded.
|
||||||
|
No missing attributes or implicit selectors are fabricated. Backend errors are
|
||||||
|
sanitized rather than forwarding raw diagnostic bodies with possible host paths.
|
||||||
|
|
||||||
|
Create and attach reuse the ordinary Workdir backend, request identity,
|
||||||
|
Worker-session router, mutation lock and Internal SubWorker lifecycle hooks.
|
||||||
|
Creation does **not** attach. Attach installs the Backend-attenuated session
|
||||||
|
capabilities, never a fabricated `ALL` capability for External Workdirs. Detach
|
||||||
|
uses the same begin/finish/cancel-detach path and command/operation exclusion as
|
||||||
|
Tools; the Backend checks expected connection identity under its existing
|
||||||
|
current-Worker lifecycle lock before closing sessions, then conditionally
|
||||||
|
updates that exact row in the existing connection ledger. Alias reuse cannot
|
||||||
|
retarget an old connection operation. Compensation retains the original
|
||||||
|
connection identity. Existing removal dispositions `removed`, `retained`, and
|
||||||
|
`attention_required`, plus retryability, remain intact.
|
||||||
|
|
||||||
|
Stale identity/validator rejection is distinct from unknown operation outcome.
|
||||||
|
Transport failure, invalid post-dispatch response or uncertain lifecycle failure
|
||||||
|
is **not** proof that no effect occurred; it must not be automatically retried.
|
||||||
|
Inspect current state or request operator guidance. No new retry or attachment
|
||||||
|
ledger is introduced by WIP.
|
||||||
|
|
||||||
|
When native management is mounted, `WorkdirList/Create/Attach/Detach/Delete` are
|
||||||
|
claimed as represented native capabilities, so `/tools/Workdir*` is not also
|
||||||
|
published. Ordinary Tools mode keeps its names and inputs. Existing native
|
||||||
|
Ticket/Objective/Merge Request routes retain their T-698 contracts.
|
||||||
|
|
||||||
|
## First-Workspace example
|
||||||
|
|
||||||
|
1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and
|
||||||
|
`workdir-attachments` when permitted.
|
||||||
|
2. Inspect the returned Repository collection Interface and call `list` with a
|
||||||
|
bounded limit. An empty registered/visible catalog explicitly returns an empty
|
||||||
|
list; do not invent a key. Follow `next_cursor` when supplied.
|
||||||
|
3. Discover the returned item `path`, inspect its Interface and call `read`.
|
||||||
|
`default_selector: null` means no configured default, not `main`.
|
||||||
|
4. Inspect the Workdir collection and `create` using the discovered formal key,
|
||||||
|
with optional selector, display name and Runtime under the existing contract.
|
||||||
|
5. Follow created `item.path`, discover/inspect and `attach` with a chosen
|
||||||
|
Worker-local alias. This is separate from creation.
|
||||||
|
6. Follow `attachments_path`, `list`, then discover/read the returned connection
|
||||||
|
path to confirm alias, Workdir and effective capabilities.
|
||||||
|
7. Call that connection's `detach` with no identity override. The observed
|
||||||
|
lifetime ID guards alias reuse; the Workdir remains in its inventory.
|
||||||
|
|
||||||
|
Each step uses current WIP observations and no Ticket-derived key, host path,
|
||||||
|
Runtime endpoint or manual validator. Explicitly refresh/reset discovery after
|
||||||
|
external changes and before reconstructing a rejected stale call.
|
||||||
@@ -52,21 +52,47 @@ The interface descriptor uses WIP `Json` for compatibility input and includes th
|
|||||||
|
|
||||||
## Native projection extension and collisions
|
## Native projection extension and collisions
|
||||||
|
|
||||||
`worker::wip::WipMountRegistry` is the Host-owned mount allocator. A Feature can independently construct a `WipProjection` with an async `WipOperationHandler`, then pass its aggregate registry through `install_wip_mode_with_mounts` before compatibility finalization. A Feature first requests a Host-owned `/features/<feature>` namespace with `allocate_feature_route`; checked direct-child joins prevent a Feature from claiming an ambient global route.
|
`worker::wip::WipMountRegistry` is the Host-owned namespace, Object, and operation registry. A provider requests a root namespace with `allocate_namespace(owner, namespace)`; the owner is retained as Host metadata and is not inserted into the public path. It then mounts the collection Object directly at `/<namespace>` and may install a bounded direct-child resolver. Features do not appear as intermediate Objects.
|
||||||
|
|
||||||
- Routes must be canonical absolute WIP paths.
|
Object resolution and operations are separate contracts. One mounted `WipProjection` owns a static Object route and its initial operations. Another Feature may add operations with `contribute_operations` without replacing that Object. Dynamic families likewise keep one `WipDynamicItemResolver`, while `contribute_dynamic_operations` supplies disjoint handlers for every resolved item without a second resolver. The Host merges only contributions with the same interface format, documentation, and type declarations; duplicate operation names, inconsistent interfaces/validators, missing targets, duplicate namespace owners, and unrelated route claims fail deterministically at registration. The merged interface validator changes with the effective operation set, so stale Known Space cannot authorize a newly contributed operation.
|
||||||
|
|
||||||
|
- Routes must be canonical absolute WIP paths, and allocated namespaces are one lowercase root segment.
|
||||||
- Object names must match the final route segment.
|
- Object names must match the final route segment.
|
||||||
- A projected object exposes exactly its declared interface.
|
- Object existence and resolution have one owner; operation contributors cannot remount or substitute the Object.
|
||||||
- Unrelated duplicate routes and conflicting interface descriptors or validators fail startup.
|
- Multiple Features may contribute disjoint operations to the same Object interface.
|
||||||
|
- Unrelated duplicate routes, duplicate operation names, and conflicting interface shapes fail startup.
|
||||||
- A bounded dynamic mount may resolve exactly one item segment below an already mounted native collection; deeper paths never fall through to the resolver.
|
- A bounded dynamic mount may resolve exactly one item segment below an already mounted native collection; deeper paths never fall through to the resolver.
|
||||||
- A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route.
|
- A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route.
|
||||||
- Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry.
|
- Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry.
|
||||||
|
|
||||||
`WipInspect` renders the complete descriptor, including descriptor-local named declarations and recursive record, list, enum, and union shapes with required flags and declaration documentation. The unit samples `mount_collision_and_native_replacement_are_explicit` and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate independent native registration, deterministic replacement, collision rejection, complete descriptor inspection, and a descriptor-typed native invocation.
|
`WipInspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation.
|
||||||
|
|
||||||
|
The built-in native route migration is:
|
||||||
|
|
||||||
|
| Historical route | Canonical route |
|
||||||
|
| --- | --- |
|
||||||
|
| `/features/ticket/tickets[/<reference>]` | `/tickets[/<reference>]` |
|
||||||
|
| `/features/objective/objectives[/<reference>]` | `/objectives[/<reference>]` |
|
||||||
|
| `/features/merge-request/merge-requests[/<reference>]` | `/merge-requests[/<reference>]` |
|
||||||
|
|
||||||
|
The built-in Repository/Workdir reference provider allocates `/repositories`, `/workdirs`, and `/workdir-attachments` using this contract; `manage-workdir` contributes operations rather than owning another Object. See [Repository, Workdir and attachment WIP projection](workdir-wip-projection.md) for identity encoding, reference/management permissions, attachment lifetime safety, and first-Workspace discovery. `/checkouts` content projection remains a separate provider.
|
||||||
|
|
||||||
|
### Registering a subsequent provider
|
||||||
|
|
||||||
|
1. Allocate `allocate_namespace("repository", "repositories")`. The returned `WipNamespaceRoute::root()` is `/repositories`; the owner is independent of any Feature registration name.
|
||||||
|
2. Mount a native collection `WipProjection` at that root with capability `repository:collection`, matching Object name and one declared interface. Static Objects underneath the root use the same capability owner prefix. A mounted collection retains its own identity, interfaces, and validator even when it has static children.
|
||||||
|
3. For dynamic items, register one `WipDynamicMount` on the collection with capability `repository:item`. A foreign capability owner is rejected. Its `WipDynamicItemResolver` owns reference acceptance, Object identity, and current Object validator. It returns only a bounded direct child; it must not reinterpret a reference as another namespace or grant permission from existence.
|
||||||
|
4. The reference provider supplies its read operations and handlers independently of management Feature enablement. An Object-only provider may use an empty operation descriptor. Enabled management Features register `WipOperationContribution` for static Objects or `WipDynamicOperationContribution` for a dynamic family; they do not allocate the namespace again or replace its resolver.
|
||||||
|
5. Contributions use the existing interface reference and identical format, documentation, and type declarations, supplying only their new operations. Interface references are global: extending a descriptor that is also mounted elsewhere must not create different descriptors/validators under the same reference (registration fails). Use separate references for collection and item contracts. Failed contributions leave the registry unchanged.
|
||||||
|
6. Handlers are responsible for checking current target capability and current subject authorization before dispatch. Registration/discovery is metadata, not a grant. A resolver/handler must retain the original permission identity and domain audit/failure contracts. Revocation after discovery must fail before provider dispatch. Compatibility mounts are restricted to `/tools/<tool-name>` and cannot bypass namespace ownership or republish legacy routes.
|
||||||
|
|
||||||
|
Tests `management_enablement_is_independent_of_read_and_operation_permissions` and `contributed_interface_invalidates_observations_and_restore_requires_discovery` cover all eight enablement/read/manage combinations, direct-call rejection, effective descriptor changes, fresh runtime isolation under the same Worker identity, reset, and post-observation revocation.
|
||||||
|
|
||||||
## Authority and execution
|
## Authority and execution
|
||||||
|
|
||||||
Discovery is not authority. The Worldspace contains only the tools already registered from enabled Features and the Worker's current execution context. Calling a compatibility operation:
|
Discovery is not authority. Object publication, operation implementation, target capability, and subject permission are independent checks. An Object may remain discoverable through an enabled read provider when a management Feature contributes no operations; conversely, publishing an operation never bypasses the target's current capability or the subject's current permission. Every handler re-evaluates permission at call time, so revocation after discovery is rejected before provider dispatch. Feature enablement selects implementations and does not itself grant read or mutation authority.
|
||||||
|
|
||||||
|
The Worldspace contains only the operations registered from enabled Features and the Worker's current execution context. Calling a compatibility operation:
|
||||||
|
|
||||||
1. resolves the exact mounted route;
|
1. resolves the exact mounted route;
|
||||||
2. verifies object and interface membership and validators;
|
2. verifies object and interface membership and validators;
|
||||||
@@ -85,6 +111,8 @@ The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions a
|
|||||||
|
|
||||||
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority.
|
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority.
|
||||||
|
|
||||||
|
Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features/<feature>/...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel.
|
||||||
|
|
||||||
## Errors, cancellation, and retries
|
## Errors, cancellation, and retries
|
||||||
|
|
||||||
The adapter keeps these outcomes distinct:
|
The adapter keeps these outcomes distinct:
|
||||||
@@ -96,6 +124,8 @@ The adapter keeps these outcomes distinct:
|
|||||||
- **disconnected/timeout after dispatch**: `wip-client` records an unknown outcome;
|
- **disconnected/timeout after dispatch**: `wip-client` records an unknown outcome;
|
||||||
- **operation outcome unknown**: an executing provider fails without a rollback/non-execution guarantee, a response cannot be completed after dispatch, or the call future is force-closed.
|
- **operation outcome unknown**: an executing provider fails without a rollback/non-execution guarantee, a response cannot be completed after dispatch, or the call future is force-closed.
|
||||||
|
|
||||||
|
Cooperative cancellation is scoped to the active execution's selected operation and exact resolved handler instance. Static/dynamic dispatchers forward `cancel_operation` only to that operation's provider, once per cancellation request, never to unrelated contributors or every alias backed by a shared handler. The runtime pins the dispatcher used for execution rather than resolving the Object again on cancellation, since dynamic resolvers may return stateful instances. Providers may implement `cancel_operation` for operation-specific control or the shared `cancel` default. Terminal completion or a dropped execution future releases the active binding; unknown/finished execution IDs do not invoke any provider. A cancellation request is not itself terminal confirmation.
|
||||||
|
|
||||||
An unknown mutating outcome must never be retried automatically. The model must inspect external state or ask for operator guidance. Retrieval failures may be explicitly refreshed.
|
An unknown mutating outcome must never be retried automatically. The model must inspect external state or ask for operator guidance. Retrieval failures may be explicitly refreshed.
|
||||||
|
|
||||||
## Measurement
|
## Measurement
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Subject / Memory information audit (T-692)
|
||||||
|
|
||||||
|
## Decision and scope
|
||||||
|
|
||||||
|
The previous Subjects index exposed `Store revision` alongside identity, state and
|
||||||
|
update time; Subject detail led with technical metadata and raw surface states.
|
||||||
|
These numbers require implementation knowledge before they help a user choose a
|
||||||
|
Subject or understand what is available. The revised hierarchy starts with
|
||||||
|
identity, connection, committed records and generated-context availability.
|
||||||
|
|
||||||
|
This is a presentation change, not a change to storage, freshness comparisons,
|
||||||
|
permissions, generation, or Worker history. T-688's ordinary Worker launch and
|
||||||
|
T-689's independent Subject creation remain the actual operation paths. T-693
|
||||||
|
owns editable user-managed behavior: that content must remain distinct from this
|
||||||
|
page's context generated from committed Memories. A successful read or a current
|
||||||
|
surface does not attest what is already in any running Worker's history.
|
||||||
|
|
||||||
|
## Field classification and before/after priorities
|
||||||
|
|
||||||
|
| Information | Classification and reason | Presentation decision |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. |
|
||||||
|
| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. |
|
||||||
|
| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. |
|
||||||
|
| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. |
|
||||||
|
| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. |
|
||||||
|
| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. |
|
||||||
|
| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. |
|
||||||
|
| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. |
|
||||||
|
| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. |
|
||||||
|
| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. |
|
||||||
|
| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. |
|
||||||
|
| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. |
|
||||||
|
| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. |
|
||||||
|
| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. |
|
||||||
|
| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. |
|
||||||
|
|
||||||
|
No diagnostic datum was removed from Subject detail, surface detail or Memory
|
||||||
|
provenance/history. The index no longer shows a store revision, but the exact
|
||||||
|
value remains one click away in Subject detail. No invented regeneration,
|
||||||
|
editing or recovery action was added; `Needs refresh` does not mean an update job
|
||||||
|
is running. Creation still creates only a Subject, and selection still uses the
|
||||||
|
existing ordinary launch contract.
|
||||||
|
|
||||||
|
## Candidate-only verification
|
||||||
|
|
||||||
|
Candidates are not committed records and are not an input to this page. The
|
||||||
|
Subject loader performs just three bounded reads: Subject, resident surface and
|
||||||
|
`/memories?limit=100[&cursor=...]`. It does not read or count staging records.
|
||||||
|
|
||||||
|
The existing real-store regression
|
||||||
|
`subjektiv::tests::candidate_decisions_reject_conflicts_and_roll_back_partial_writes`
|
||||||
|
stages a candidate and attempts an invalid apply. It asserts the candidate has no
|
||||||
|
resolution and the committed Memory list remains empty. The component regression
|
||||||
|
`candidate-only Subjects show no committed Memories, not an inferred candidate count`
|
||||||
|
uses that read projection (store revision 0, ungenerated surface, empty successful
|
||||||
|
committed list) and checks `None`, `No committed Memories yet.`, `Not connected`
|
||||||
|
and `Not generated`, with no resident body. Thus a staged candidate does not turn
|
||||||
|
into a committed-Memory count or a claim that context has been generated.
|
||||||
|
|
||||||
|
The store test proves the persistence boundary; the component test proves the
|
||||||
|
rendering of its public projection. These are separate boundary tests, **not** a
|
||||||
|
live browser-to-Backend candidate-creation E2E. The existing `ungenerated` capture
|
||||||
|
represents the same visible projection, but does not itself stage a candidate.
|
||||||
|
|
||||||
|
## Visual review and validation evidence
|
||||||
|
|
||||||
|
The restored implementation is in `fbb09e65` and `4cf6c130`; this report/test
|
||||||
|
supplement does not change rendered markup, styling, copy or data contracts.
|
||||||
|
|
||||||
|
- Scenarios: `tools/web-ux/scenarios/memory-subjects-{light,dark}.json`.
|
||||||
|
- Before: `target/web-ux/t-692-before-{light,dark}` at `cc979874`.
|
||||||
|
- After: `target/web-ux/t-692-final2-{light,dark}` at clean `4cf6c130`.
|
||||||
|
- Comparisons: `target/web-ux/t-692-final2-{light,dark}-comparison`.
|
||||||
|
- Browser interactions: `target/web-ux/t-692-browser-interactions`.
|
||||||
|
- Persona: synthetic Workspace fixture; permitted creation and intentional
|
||||||
|
permission-rejection responses. No real account or private store data used.
|
||||||
|
- Viewports: 1440, 768, 390 and 320px; light and dark themes.
|
||||||
|
- States: connected/unconnected, active/retired, successful empty lists, ready
|
||||||
|
long Markdown, ready-empty, ungenerated, stale, failed, unavailable HTTP503,
|
||||||
|
create form and create permission error; expanded technical disclosures.
|
||||||
|
- The takeover implementer personally inspected individual before/after images
|
||||||
|
for index at 768px, representative detail at desktop/mobile and Memory detail,
|
||||||
|
then ready-empty mobile, ungenerated narrow, stale desktop, failed mobile,
|
||||||
|
endpoint error at 320px, mobile surface sources, compact Subject diagnostics
|
||||||
|
and mobile permission error. Oversized contact sheets were rejected by the
|
||||||
|
image provider; individual viewport PNGs were inspected instead.
|
||||||
|
- Findings/result: primary state groups replace counters without losing IDs or
|
||||||
|
document access; long IDs wrap, status is textual rather than color-only,
|
||||||
|
mobile groups stack, diagnostics remain readable, and error state regions stay
|
||||||
|
separate. No new visual defect identified. **Pass for the audited scope.**
|
||||||
|
- Final bundles contain 64 captures each. `completed-with-errors` is expected:
|
||||||
|
intentional creation HTTP403, endpoint HTTP503/associated aborted requests,
|
||||||
|
and the deliberately failed surface's alert are retained as evidence. Healthy
|
||||||
|
capture states have empty error arrays; this is not a blanket claim that all
|
||||||
|
captures were error-free. Review-context diagnostics are empty.
|
||||||
|
- The focused production-shell browser test proves main-owned vertical scrolling
|
||||||
|
with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow,
|
||||||
|
local table/code scroll, Markdown safety, creation/launch separation, bounded
|
||||||
|
pagination and Memory/source/revision reachability.
|
||||||
|
|
||||||
|
Independent review at source `4cf6c130` ran root `cargo check`,
|
||||||
|
`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12
|
||||||
|
Memory API tests, 452 full Web unit tests, 134 component tests (8 Memory tests),
|
||||||
|
production build, the focused production-shell browser test (1), and Web-UX unit
|
||||||
|
tests (16), all passing.
|
||||||
|
|
||||||
|
The takeover implementer then validated this report/test supplement with:
|
||||||
|
|
||||||
|
- `cargo test -p yoi-workspace-server --lib candidate_decisions_reject_conflicts_and_roll_back_partial_writes`: 1 passed.
|
||||||
|
- Root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`: passed.
|
||||||
|
- Targeted Memory component tests: 9 passed, including the added candidate-only projection test.
|
||||||
|
- Web `deno task check`: 0 errors/warnings; `deno task test`: 452 passed;
|
||||||
|
`deno task test:component`: 135 passed.
|
||||||
|
- Scoped `deno fmt --check` on the changed TypeScript test: passed.
|
||||||
|
- JSON audit of both final2 bundles: 64 captures each, exact clean source
|
||||||
|
`4cf6c130`, and no errors outside the deliberate permission/error/failed routes.
|
||||||
|
|
||||||
|
The supplement changes only a document and a test, so it does not require new
|
||||||
|
rendered-UI captures. Rereview records and the final approved Ticket handoff link
|
||||||
|
this report to the new exact source.
|
||||||
|
|
||||||
|
Known non-blocking validation diagnostics: component runs emitted incidental
|
||||||
|
localhost:3000 connection-refused messages while passing; the production build
|
||||||
|
warned about a large chunk. A broader Web-UX typecheck fails in unchanged
|
||||||
|
`tools/web-ux/src/processes.ts:44` on a `TextDecoderStream` generic incompatibility;
|
||||||
|
this report does not claim that broad check passed.
|
||||||
|
|
||||||
|
No live-Backend browser interactions, real Worker model-request propagation,
|
||||||
|
regeneration, or server/runtime process E2E were performed. No Rust production
|
||||||
|
crate changed in T-692; the supplemental store test exercises an existing
|
||||||
|
contract without changing that contract. Backend counters, comparison logic,
|
||||||
|
storage schema and feature behavior remain intact.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Review finding severity contract mismatch during T-697
|
||||||
|
|
||||||
|
The independent Reviewer completed source/requirement analysis and validation,
|
||||||
|
then its one `ReviewMergeRequest` submission was rejected with HTTP 422 because
|
||||||
|
it used severity `medium`. The tool exposed a free-form string, while Backend
|
||||||
|
validation accepts only `blocker`, `major`, `minor`, or `note`. No verdict was
|
||||||
|
recorded by that failed call. The child preserved the source and reported the
|
||||||
|
blocked submission rather than loop-retrying a mutation.
|
||||||
|
|
||||||
|
The parent inspected the committed review session and fresh MR authority,
|
||||||
|
confirmed the same request/source/Ticket snapshot with no verdict, and submitted
|
||||||
|
a fresh child turn to correct the input contract. The Reviewer independently
|
||||||
|
selected supported severities and recorded `request_changes` successfully on
|
||||||
|
the same MR. No alternate token/context, CLI/storage write, source movement or
|
||||||
|
replacement MR was used to compensate for the rejection.
|
||||||
|
|
||||||
|
Suggested improvement: expose finding severity as the typed Backend enum in the
|
||||||
|
LLM tool schema, and document allowed values in the Reviewer prompt. Rejecting
|
||||||
|
an unadvertised enum after a costly review should not prevent durable findings
|
||||||
|
from reaching the MR. This report is tool feedback, not a T-697 product
|
||||||
|
constraint or a request to alter review judgments.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Reviewer verdict のvalidation rejectionとone-call制約
|
||||||
|
|
||||||
|
## 確認した事実
|
||||||
|
|
||||||
|
T-703のMR `01a10b3b-87ec-7093-b0c4-a00a78095695`、source
|
||||||
|
`3174142bd1314fe34c5de7b6a66654ff1c2a8af2` の独立ReviewerはRestoreとno-op
|
||||||
|
attention checkの競合を見つけ、`ReviewMergeRequest`でrequest_changesを提出しようとした。
|
||||||
|
severityに`medium`を指定したためHTTP 422となった。Backendの許容値は
|
||||||
|
`blocker` / `major` / `minor` / `note` だった。
|
||||||
|
|
||||||
|
親の`ShowMergeRequest`再読でthreadはReviewRequested seq2までであり、verdictが
|
||||||
|
永続化されていないことを確認した。Reviewerは「ReviewMergeRequestを正確に一度だけ
|
||||||
|
呼ぶ」という指示に従い、validation rejection後も再提出を行わなかった。親のfollow-upでも
|
||||||
|
同じ境界を報告したため、再提出を強制せずReviewerを停止した。
|
||||||
|
|
||||||
|
## 障壁
|
||||||
|
|
||||||
|
正しいBackend拒否を迂回すべきではない一方、mutationが行われていない入力validation失敗が
|
||||||
|
一回限りの提出枠を消費する扱いだと、レビュー済みの指摘がMR authorityに残らずpendingになる。
|
||||||
|
セッションでの報告をverdictや承認の代わりにはできない。
|
||||||
|
|
||||||
|
## 改善案(このTicketの実装対象ではない)
|
||||||
|
|
||||||
|
- Reviewer-facing tool schemaにseverityのBackend enumを明示し、不正値を入力時に防ぐ。
|
||||||
|
- one-call指示について「一度のdurable verdict」と「validationで拒否された呼出」を区別できるか
|
||||||
|
設計上判断する。無制限再提出や記録済みverdictの変更を認める提案ではない。
|
||||||
|
- mutationなし/retry可否をtyped errorとして返せると、親とReviewerの双方が推測なしに扱える。
|
||||||
|
|
||||||
|
T-703では元のMR/source selectorを保持し、指摘を修正したcommitを通常pushした後、fresh
|
||||||
|
exact-source reviewで修正内容とその証拠をMRに記録する。未記録のverdictを承認扱いしない。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Reviewer SubWorkerがreview開始前に停止する
|
||||||
|
|
||||||
|
## 状況
|
||||||
|
|
||||||
|
T-698の公開済みMerge Requestに対して、`builtin:reviewer`を指定し、対象Ticket/Merge Request、writable delegated scope、command grantを明示してReviewer SubWorkerを3回起動した。
|
||||||
|
|
||||||
|
いずれも`review_requested` eventは正しいsource refとTicket revisionを記録したが、SubWorkerは直後に`Stopped`となった。session observationには最初のuser taskだけが存在し、assistant output、tool call、typed review verdictは記録されなかった。Merge Request threadにもapprove/request_changesは追加されなかった。
|
||||||
|
|
||||||
|
## 障壁
|
||||||
|
|
||||||
|
CoderはReviewer approvalを代行できず、親Workerには`ReviewMergeRequest` authorityがないため、実装・公開・検証が完了していてもfresh approvalを取得できない。同じ入力を再送するだけでは診断情報が増えず、review requestだけが複数残る。
|
||||||
|
|
||||||
|
## 改善案
|
||||||
|
|
||||||
|
- Reviewer起動がmodel run前に停止した場合、停止理由を親へ構造化して通知する。
|
||||||
|
- `review_requested`を記録した後にReviewer sessionの開始に失敗した場合、request eventと失敗eventを関連付け、未解決reviewとして明示する。
|
||||||
|
- profile/provider初期化、review worktree準備、capability bindingのどの段階で失敗したかを、credentialやhost pathを露出しないbounded diagnosticとして提供する。
|
||||||
|
- 同一sourceへの再試行時は、先行Reviewerが一度も実行されていないことを識別できるようにする。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# SubWorker cwd attachment-root ambiguity during T-697
|
||||||
|
|
||||||
|
## Observed barrier
|
||||||
|
|
||||||
|
A delegated backend investigation was spawned with `cwd=crates/workspace-server`
|
||||||
|
and explicit writable rules for both `crates/workspace-server` and
|
||||||
|
`crates/server-api`, plus repository-root read access. The child reported that
|
||||||
|
its available attachment exposed only the workspace-server crate; its observed
|
||||||
|
file reads used `src/server.rs`/`src/lib.rs` and it could not access server-api.
|
||||||
|
It also had no `ShowTicket` or parent-notification tool, despite the parent
|
||||||
|
having typed Ticket authority. No implementation changes were made by that child.
|
||||||
|
|
||||||
|
The parent inspected the committed child session, released the idle child, and
|
||||||
|
respawned at the repository root with the same delegated write boundaries and
|
||||||
|
an explicit summary of the authoritative Ticket requirements. That child could
|
||||||
|
then implement the cross-crate API boundary. No filesystem capability rejection
|
||||||
|
was worked around through another write path.
|
||||||
|
|
||||||
|
## Suggested improvement
|
||||||
|
|
||||||
|
The advertised SubWorker `cwd` contract says that it changes only the tool
|
||||||
|
working directory, not attachment roots. Show the effective attachment aliases,
|
||||||
|
logical roots and readable/writable scopes explicitly to the child and parent,
|
||||||
|
and test cross-crate reads/writes for a non-root cwd. If cwd intentionally
|
||||||
|
narrows or rebases an attachment, document that instead of presenting it as
|
||||||
|
only a command default. A denied/inaccessible sibling diagnostic should identify
|
||||||
|
the effective logical attachment root without disclosing unauthorized paths.
|
||||||
|
|
||||||
|
Also expose which typed domain and parent-messaging tools will be available
|
||||||
|
before spawning. Delegated implementation requires authoritative requirements;
|
||||||
|
where Ticket reads are unavailable, the parent must deliberately provide the
|
||||||
|
bounded contract rather than assume the child can recover it by ID. This report
|
||||||
|
records tool ergonomics only; it is not a new T-697 product requirement.
|
||||||
+152
-1
@@ -9807,9 +9807,32 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SubjektivSubjectBehaviorUpdateRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"behavior_md": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_behavior_revision": {
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expected_behavior_revision",
|
||||||
|
"behavior_md"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SubjektivSubjectCreateRequest": {
|
"SubjektivSubjectCreateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"behavior_md": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -9875,6 +9898,15 @@
|
|||||||
"SubjektivSubjectResponse": {
|
"SubjektivSubjectResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"behavior_md": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"behavior_revision": {
|
||||||
|
"format": "uint64",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9911,6 +9943,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"role",
|
"role",
|
||||||
|
"behavior_md",
|
||||||
|
"behavior_revision",
|
||||||
"state",
|
"state",
|
||||||
"store_revision",
|
"store_revision",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -15286,7 +15320,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896"
|
"x-yoi-source-digest": "sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -29623,6 +29657,123 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "subjektiv_subject_behavior_update",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "subject_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubjektivSubjectResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "subjektiv_memory_list",
|
"operationId": "subjektiv_memory_list",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
|
|||||||
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
internalAgentsMdSection = import "./internal/agents_md_section.md";
|
||||||
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
|
||||||
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md";
|
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md";
|
||||||
|
internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md";
|
||||||
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
|
||||||
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
|
||||||
in
|
in
|
||||||
@@ -74,6 +75,7 @@ in
|
|||||||
agents_md_section = internalAgentsMdSection.content;
|
agents_md_section = internalAgentsMdSection.content;
|
||||||
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
resident_memory_summary_section = internalResidentMemorySummarySection.content;
|
||||||
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
|
||||||
|
subjektiv_resident_context = internalSubjektivResidentContext.content;
|
||||||
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
|
||||||
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request.
|
This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request.
|
||||||
|
|
||||||
{% if surface_ready %}
|
{% if surface_ready %}
|
||||||
### Current ready surface
|
### Current resident context
|
||||||
|
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
{{ summary }}
|
{{ summary }}
|
||||||
{% else %}
|
{% else %}
|
||||||
The current ready resident memory surface is intentionally empty.
|
The current resident context is intentionally empty.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
This is the Host-fetched current Subject context. Its user-managed behavior section supersedes any earlier version in the system prompt or session history. Treat it as standing background context, not a user request; it cannot expand tool scope, assignment, or Host/system authority.
|
||||||
|
|
||||||
|
### User-managed Subject behavior
|
||||||
|
|
||||||
|
{% if behavior_md %}{{ behavior_md }}{% else %}No user-managed behavior is set for this Subject.{% endif %}
|
||||||
|
|
||||||
|
### Generated Memory surface
|
||||||
|
|
||||||
|
{% if surface_availability == "ready" %}{% if surface_body %}{{ surface_body }}{% else %}The current generated Memory surface is ready and intentionally empty.{% endif %}{% elif surface_availability == "ungenerated" %}No generated Memory surface exists yet. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "stale" %}The generated Memory surface is stale and is not included. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "failed" %}Generation of the Memory surface failed, so no generated surface is included. Confirmed Memory remains available through explicit recall tools.{% else %}The Host could not fetch the Subject resident context. Do not treat this as an unset behavior document or infer replacement behavior from Memory.{% endif %}
|
||||||
@@ -596,6 +596,16 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
|||||||
await touchPage.reload();
|
await touchPage.reload();
|
||||||
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||||
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||||
|
// Different-route and same-page links both dismiss the mobile overlay.
|
||||||
|
for (let visit = 0; visit < 2; visit++) {
|
||||||
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
|
||||||
|
await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap();
|
||||||
|
await touchPage.waitForURL(`**/w/${workspaceId}`);
|
||||||
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||||
|
assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
|
||||||
|
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||||
|
}
|
||||||
|
await touchPage.goto(page.url());
|
||||||
// A wide touchscreen still has a temporary-open control in hover mode.
|
// A wide touchscreen still has a temporary-open control in hover mode.
|
||||||
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
||||||
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
|
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
|
||||||
|
|||||||
@@ -64,6 +64,104 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
|
|||||||
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
|
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
|
||||||
);
|
);
|
||||||
await page.locator(".task-mini-row").waitFor();
|
await page.locator(".task-mini-row").waitFor();
|
||||||
|
const metadata = page.getByLabel("Worker model and context", { exact: true });
|
||||||
|
const statusGeometry = await metadata.evaluate((status) => {
|
||||||
|
const task = document.querySelector(".task-mini-row")!;
|
||||||
|
const composer = document.querySelector(".console-composer")!;
|
||||||
|
const metrics = (node: Element) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return [style.fontFamily, style.fontSize, style.lineHeight];
|
||||||
|
};
|
||||||
|
const rect = status.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
typography: metrics(status),
|
||||||
|
taskTypography: metrics(task),
|
||||||
|
height: rect.height,
|
||||||
|
belowComposer: rect.top >= composer.getBoundingClientRect().bottom,
|
||||||
|
alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left,
|
||||||
|
visible: rect.bottom <= innerHeight,
|
||||||
|
wrap: getComputedStyle(status).flexWrap,
|
||||||
|
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assertEquals(statusGeometry.typography, statusGeometry.taskTypography);
|
||||||
|
assertEquals(statusGeometry.height, 24);
|
||||||
|
assertEquals(statusGeometry.belowComposer, true);
|
||||||
|
assertEquals(statusGeometry.alignedWithTasks, true);
|
||||||
|
assertEquals(statusGeometry.visible, true);
|
||||||
|
assertEquals(statusGeometry.wrap, "nowrap");
|
||||||
|
assertEquals(statusGeometry.pageOverflow, false);
|
||||||
|
const details = metadata.getByRole("button", { name: "Details", exact: true });
|
||||||
|
assertEquals(await page.locator(".console-header").count(), 0);
|
||||||
|
const detailsGeometry = await details.evaluate((button) => {
|
||||||
|
const row = button.closest(".worker-context-status")!;
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1,
|
||||||
|
centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1,
|
||||||
|
width: rect.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assertEquals(detailsGeometry.rightAligned, true);
|
||||||
|
assertEquals(detailsGeometry.centered, true);
|
||||||
|
assertEquals(detailsGeometry.width, 24);
|
||||||
|
const overview = metadata.getByRole("switch", { name: "Overview", exact: true });
|
||||||
|
const controls = await overview.evaluate((group) => {
|
||||||
|
const row = group.closest(".worker-context-status")!;
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const buttons = [...row.querySelectorAll("button")];
|
||||||
|
return {
|
||||||
|
buttonsInsideRow: buttons.every((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return rect.left >= rowRect.left && rect.right <= rowRect.right &&
|
||||||
|
rect.top >= rowRect.top && rect.bottom <= rowRect.bottom;
|
||||||
|
}),
|
||||||
|
heights: buttons.map((button) => button.getBoundingClientRect().height),
|
||||||
|
modeFont: getComputedStyle(buttons[0]).fontFamily,
|
||||||
|
divider: getComputedStyle(buttons[1]).borderLeftWidth,
|
||||||
|
beforeDetails: group.nextElementSibling?.getAttribute("aria-label") === "Details",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assertEquals(controls.buttonsInsideRow, true);
|
||||||
|
assertEquals(controls.heights, [24, 24]);
|
||||||
|
assertEquals(controls.modeFont.includes("mono"), false);
|
||||||
|
assertEquals(controls.divider, "0px");
|
||||||
|
assertEquals(controls.beforeDetails, true);
|
||||||
|
const onSize = (await overview.boundingBox())!;
|
||||||
|
assert(onSize.width < 90, "Switch and fixed label should use less than 90px");
|
||||||
|
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||||
|
await overview.focus();
|
||||||
|
assertEquals(await overview.evaluate((button) => getComputedStyle(button).outlineStyle), "solid");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
assertEquals(await overview.getAttribute("aria-checked"), "false");
|
||||||
|
assertEquals((await overview.textContent())!.trim(), "Overview");
|
||||||
|
assertEquals((await overview.boundingBox())!.width, onSize.width);
|
||||||
|
if (mode === "both") {
|
||||||
|
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
|
||||||
|
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}-off.png`) });
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||||
|
assertEquals((await overview.boundingBox())!.width, onSize.width);
|
||||||
|
await overview.locator("span").last().click();
|
||||||
|
assertEquals(await overview.getAttribute("aria-checked"), "false");
|
||||||
|
await overview.locator(".console-view-toggle-track").click();
|
||||||
|
assertEquals(await overview.getAttribute("aria-checked"), "true");
|
||||||
|
await details.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
assertEquals(await details.getAttribute("aria-expanded"), "true");
|
||||||
|
await page.getByRole("button", { name: "Close", exact: true }).waitFor();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
assertEquals(await details.getAttribute("aria-expanded"), "false");
|
||||||
|
await details.click();
|
||||||
|
assertEquals(await details.getAttribute("aria-expanded"), "true");
|
||||||
|
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
assertEquals(await details.getAttribute("aria-expanded"), "false");
|
||||||
|
if (mode === "both") {
|
||||||
|
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
|
||||||
|
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}.png`) });
|
||||||
|
}
|
||||||
const queue = mode === "both" || mode === "queue";
|
const queue = mode === "both" || mode === "queue";
|
||||||
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
const notifications = ["both", "notifications", "legacy"].includes(mode);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ ${"detail-wide-code-".repeat(18)}
|
|||||||
<script>alert("not rendered")</script>`;
|
<script>alert("not rendered")</script>`;
|
||||||
|
|
||||||
const subjects = [
|
const subjects = [
|
||||||
subject(representativeSubjectId, "Release coordination", "active", 42),
|
subject(
|
||||||
|
representativeSubjectId,
|
||||||
|
"Release coordination",
|
||||||
|
"active",
|
||||||
|
42,
|
||||||
|
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
|
||||||
|
3,
|
||||||
|
connectedWorker("Release coordination Worker with a deliberately long display name"),
|
||||||
|
),
|
||||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||||
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
||||||
subject(failedSubjectId, "Failed surface subject", "active", 8),
|
subject(failedSubjectId, "Failed surface subject", "active", 8),
|
||||||
@@ -75,14 +83,49 @@ const subjects = [
|
|||||||
];
|
];
|
||||||
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
|
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
|
||||||
|
|
||||||
function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) {
|
function connectedWorker(displayName: string) {
|
||||||
|
return {
|
||||||
|
runtime_id: "embedded-worker-runtime",
|
||||||
|
worker_id: "release-coordination-worker",
|
||||||
|
host_id: "fixture-host",
|
||||||
|
display_name: displayName,
|
||||||
|
label: displayName,
|
||||||
|
profile: "builtin:companion",
|
||||||
|
singleton_key: `subjektiv:${representativeSubjectId}`,
|
||||||
|
tags: [],
|
||||||
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: workspaceId,
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
},
|
||||||
|
state: "idle",
|
||||||
|
last_seen_at: "2026-01-02T03:04:05Z",
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "retained",
|
||||||
|
implementation: { kind: "embedded", display_hint: "Fixture Worker" },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subject(
|
||||||
|
id: string,
|
||||||
|
role: string,
|
||||||
|
state: "active" | "retired",
|
||||||
|
storeRevision: number,
|
||||||
|
behaviorMd = "",
|
||||||
|
behaviorRevision = 0,
|
||||||
|
currentWorker?: ReturnType<typeof connectedWorker>,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role,
|
role,
|
||||||
|
behavior_md: behaviorMd,
|
||||||
|
behavior_revision: behaviorRevision,
|
||||||
state,
|
state,
|
||||||
store_revision: storeRevision,
|
store_revision: storeRevision,
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-02T03:04:05Z",
|
updated_at: "2026-01-02T03:04:05Z",
|
||||||
|
...(currentWorker ? { current_worker: currentWorker } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +168,7 @@ function memoriesFor(subjectId: string) {
|
|||||||
1,
|
1,
|
||||||
"working_assumption",
|
"working_assumption",
|
||||||
"retracted",
|
"retracted",
|
||||||
"T-672 UNIQUE END MARKER",
|
"Memory list end marker for scroll validation",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -235,7 +278,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) {
|
|||||||
evidence_id: "evidence-message-0001",
|
evidence_id: "evidence-message-0001",
|
||||||
origin: { kind: "human_input", account_id: "account-fixture" },
|
origin: { kind: "human_input", account_id: "account-fixture" },
|
||||||
evidence_kind: "message",
|
evidence_kind: "message",
|
||||||
label: "T-672 product direction",
|
label: "Memory information design source",
|
||||||
summary: "Bounded source reference retained with the committed revision.",
|
summary: "Bounded source reference retained with the committed revision.",
|
||||||
}],
|
}],
|
||||||
source_refs_total: 1,
|
source_refs_total: 1,
|
||||||
@@ -403,7 +446,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||||
if (url.pathname === subjectsPath) {
|
if (url.pathname === subjectsPath) {
|
||||||
if (request.method === "POST") {
|
if (request.method === "POST") {
|
||||||
const payload = await request.json().catch(() => null) as { role?: unknown } | null;
|
const payload = await request.json().catch(() => null) as {
|
||||||
|
role?: unknown;
|
||||||
|
behavior_md?: unknown;
|
||||||
|
} | null;
|
||||||
subjectCreateRequests.push(payload);
|
subjectCreateRequests.push(payload);
|
||||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
||||||
@@ -411,9 +457,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (payload.role === "Permission denied") {
|
if (payload.role === "Permission denied") {
|
||||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
||||||
}
|
}
|
||||||
|
if (typeof payload.behavior_md !== "string") {
|
||||||
|
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
|
||||||
|
}
|
||||||
createdSubjectCount += 1;
|
createdSubjectCount += 1;
|
||||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
||||||
const created = subject(id, payload.role, "active", 0);
|
const created = subject(id, payload.role, "active", 0, payload.behavior_md);
|
||||||
createdSubjectIds.add(id);
|
createdSubjectIds.add(id);
|
||||||
subjects.unshift(created);
|
subjects.unshift(created);
|
||||||
return json(created, 201);
|
return json(created, 201);
|
||||||
@@ -442,6 +491,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
const foundSubject = subjects.find((item) => item.id === subjectId);
|
const foundSubject = subjects.find((item) => item.id === subjectId);
|
||||||
if (!foundSubject) return json({}, 404);
|
if (!foundSubject) return json({}, 404);
|
||||||
|
|
||||||
|
if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") {
|
||||||
|
const payload = await request.json().catch(() => null) as {
|
||||||
|
expected_behavior_revision?: unknown;
|
||||||
|
behavior_md?: unknown;
|
||||||
|
} | null;
|
||||||
|
if (
|
||||||
|
!payload ||
|
||||||
|
typeof payload.expected_behavior_revision !== "number" ||
|
||||||
|
typeof payload.behavior_md !== "string"
|
||||||
|
) {
|
||||||
|
return json({ error: "Bad Request", message: "invalid behavior update" }, 400);
|
||||||
|
}
|
||||||
|
if (payload.expected_behavior_revision !== foundSubject.behavior_revision) {
|
||||||
|
return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409);
|
||||||
|
}
|
||||||
|
foundSubject.behavior_md = payload.behavior_md;
|
||||||
|
foundSubject.behavior_revision += 1;
|
||||||
|
foundSubject.updated_at = "2026-01-03T04:05:06Z";
|
||||||
|
return json(foundSubject);
|
||||||
|
}
|
||||||
|
if (request.method !== "GET") return new Response("method not allowed", { status: 405 });
|
||||||
if (parts.length === 1) return json(foundSubject);
|
if (parts.length === 1) return json(foundSubject);
|
||||||
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
||||||
if (parts.length === 2 && parts[1] === "memories") {
|
if (parts.length === 2 && parts[1] === "memories") {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const memoryId =
|
|||||||
const subjectPageCursor = "fixture-subject-page-2";
|
const subjectPageCursor = "fixture-subject-page-2";
|
||||||
const createInteractionArtifacts = join(
|
const createInteractionArtifacts = join(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
"target/web-ux/t-689-browser-interactions",
|
"target/web-ux/t-692-browser-interactions",
|
||||||
);
|
);
|
||||||
|
|
||||||
async function freePort(): Promise<number> {
|
async function freePort(): Promise<number> {
|
||||||
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
|||||||
const document = (globalThis as any).document;
|
const document = (globalThis as any).document;
|
||||||
const main = document.querySelector("main");
|
const main = document.querySelector("main");
|
||||||
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
||||||
heading.textContent === "T-672 UNIQUE END MARKER"
|
heading.textContent === "Memory list end marker for scroll validation"
|
||||||
);
|
);
|
||||||
if (!main || !marker) return false;
|
if (!main || !marker) return false;
|
||||||
const mainRect = main.getBoundingClientRect();
|
const mainRect = main.getBoundingClientRect();
|
||||||
@@ -111,6 +111,7 @@ async function openSubjectPage(
|
|||||||
|
|
||||||
await hideSidebar.click();
|
await hideSidebar.click();
|
||||||
await showSidebar.waitFor();
|
await showSidebar.waitFor();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
||||||
@@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
});
|
});
|
||||||
page.on("pageerror", (error) => errors.push(String(error)));
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
await openSubjectPage(page, baseUrl, scenario.mobile);
|
await openSubjectPage(page, baseUrl, scenario.mobile);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("article", { name: "User-managed Subject behavior" }).count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: join(createInteractionArtifacts, `subject-${scenario.label}.png`),
|
||||||
|
});
|
||||||
|
|
||||||
const initial = await mainScrollState(page);
|
const initial = await mainScrollState(page);
|
||||||
assert(initial.scrollHeight > initial.clientHeight);
|
assert(initial.scrollHeight > initial.clientHeight);
|
||||||
@@ -262,7 +270,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
|
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
|
||||||
.count(),
|
.count(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -282,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
await page.locator("main").hover();
|
await page.locator("main").hover();
|
||||||
await page.mouse.wheel(0, 100_000);
|
await page.mouse.wheel(0, 100_000);
|
||||||
} else if (scenario.label === "keyboard") {
|
} else if (scenario.label === "keyboard") {
|
||||||
await page.getByText("Surface provenance", { exact: true }).focus();
|
await page.getByText("Surface sources and diagnostics", { exact: true }).focus();
|
||||||
await page.keyboard.press("End");
|
await page.keyboard.press("End");
|
||||||
} else {
|
} else {
|
||||||
await swipeMainToEnd(page);
|
await swipeMainToEnd(page);
|
||||||
@@ -313,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
level: 1,
|
level: 1,
|
||||||
}).waitFor();
|
}).waitFor();
|
||||||
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
||||||
assertEquals(await detailPage.getByText("T-672 product direction").count(), 1);
|
assertEquals(await detailPage.getByText("Memory information design source").count(), 1);
|
||||||
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
|
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
|
||||||
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
|
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
|
assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
|
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
|
||||||
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
|
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
|
||||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||||
"link",
|
"link",
|
||||||
{ name: "Next page →" },
|
{ name: "Next page →" },
|
||||||
).click();
|
).click();
|
||||||
await detailPage.getByText("Revision 1", { exact: true }).waitFor();
|
await detailPage.getByText("Memory revision 1", { exact: true }).waitFor();
|
||||||
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
|
assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
|
||||||
@@ -352,6 +360,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
const paginationPage = await paginationContext.newPage();
|
const paginationPage = await paginationContext.newPage();
|
||||||
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||||
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
|
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await paginationPage.getByText(
|
||||||
|
"Release coordination Worker with a deliberately long display name",
|
||||||
|
{ exact: true },
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0);
|
||||||
|
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
|
||||||
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
|
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
|
||||||
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
|
||||||
"link",
|
"link",
|
||||||
@@ -425,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
const createPage = await createContext.newPage();
|
const createPage = await createContext.newPage();
|
||||||
const mutationPaths: string[] = [];
|
const mutationPaths: string[] = [];
|
||||||
createPage.on("request", (request) => {
|
createPage.on("request", (request) => {
|
||||||
if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname);
|
if (["POST", "PATCH"].includes(request.method())) {
|
||||||
|
mutationPaths.push(new URL(request.url()).pathname);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
|
await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
|
||||||
await createPage.getByText("Subject on the next page").waitFor();
|
await createPage.getByText("Subject on the next page").waitFor();
|
||||||
@@ -444,26 +463,65 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
await createPage.getByRole("button", { name: "Cancel" }).click();
|
await createPage.getByRole("button", { name: "Cancel" }).click();
|
||||||
await createPage.getByRole("button", { name: "New Subject" }).click();
|
await createPage.getByRole("button", { name: "New Subject" }).click();
|
||||||
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
|
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
|
||||||
|
await createPage.getByRole("textbox", { name: /Behavior/ }).fill(
|
||||||
|
"Challenge unsupported assumptions before release.",
|
||||||
|
);
|
||||||
await createPage.getByRole("button", { name: "Create Subject" }).click();
|
await createPage.getByRole("button", { name: "Create Subject" }).click();
|
||||||
await createPage.waitForURL("**/memory/created-subject-0001");
|
await createPage.waitForURL("**/memory/created-subject-0001");
|
||||||
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
|
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
(await createPage.getByRole("article", {
|
||||||
|
name: "User-managed Subject behavior",
|
||||||
|
}).textContent())?.includes("Challenge unsupported assumptions before release."),
|
||||||
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
const currentWorker = createPage.locator(".subject-facts div").filter({
|
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
|
||||||
hasText: "Current worker",
|
"..",
|
||||||
|
);
|
||||||
|
assert((await currentWorker.textContent())?.includes("Not connected"));
|
||||||
|
await createPage.getByText("Resident context has not been generated.").waitFor();
|
||||||
|
await createPage.getByText("No committed Memories yet.").waitFor();
|
||||||
|
await createPage.getByRole("button", { name: "Edit" }).click();
|
||||||
|
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
|
||||||
|
await behaviorEditor.fill(
|
||||||
|
"Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.",
|
||||||
|
);
|
||||||
|
await createPage.screenshot({
|
||||||
|
path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"),
|
||||||
|
});
|
||||||
|
await createPage.getByRole("button", { name: "Save behavior" }).click();
|
||||||
|
await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor();
|
||||||
|
assert(
|
||||||
|
(await createPage.getByRole("status").filter({
|
||||||
|
hasText: "This page does not confirm application",
|
||||||
|
}).textContent())?.includes("does not confirm application"),
|
||||||
|
);
|
||||||
|
await createPage.screenshot({
|
||||||
|
path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"),
|
||||||
});
|
});
|
||||||
assert((await currentWorker.textContent())?.includes("None"));
|
|
||||||
assertEquals(mutationPaths, [
|
assertEquals(mutationPaths, [
|
||||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||||
|
`/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`,
|
||||||
]);
|
]);
|
||||||
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
||||||
.json();
|
.json();
|
||||||
assertEquals(createEvidence, {
|
assertEquals(createEvidence, {
|
||||||
count: 2,
|
count: 2,
|
||||||
requests: [{ role: "Permission denied" }, { role: " Release steward " }],
|
requests: [
|
||||||
|
{ role: "Permission denied", behavior_md: "" },
|
||||||
|
{
|
||||||
|
role: " Release steward ",
|
||||||
|
behavior_md: "Challenge unsupported assumptions before release.",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
await assertNoPageWideOverflow(createPage);
|
await assertNoPageWideOverflow(createPage);
|
||||||
await createContext.close();
|
await createContext.close();
|
||||||
@@ -472,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
const stateCases = [
|
const stateCases = [
|
||||||
{
|
{
|
||||||
id: "empty-subject",
|
id: "empty-subject",
|
||||||
text: "Resident surface is ready and empty.",
|
text: "Resident context is current but empty.",
|
||||||
|
role: "status" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stale-subject",
|
||||||
|
text: "Resident context needs to be refreshed.",
|
||||||
role: "status" as const,
|
role: "status" as const,
|
||||||
},
|
},
|
||||||
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
|
|
||||||
{
|
{
|
||||||
id: "failed-subject",
|
id: "failed-subject",
|
||||||
text: "Resident surface generation failed.",
|
text: "Resident context generation failed.",
|
||||||
role: "alert" as const,
|
role: "alert" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ungenerated-subject",
|
id: "ungenerated-subject",
|
||||||
text: "Resident surface has not been generated.",
|
text: "Resident context has not been generated.",
|
||||||
role: "status" as const,
|
role: "status" as const,
|
||||||
},
|
},
|
||||||
{ id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const },
|
{
|
||||||
|
id: "error-subject",
|
||||||
|
text: "Resident context status unavailable.",
|
||||||
|
role: "alert" as const,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for (const stateCase of stateCases) {
|
for (const stateCase of stateCases) {
|
||||||
const page = await stateContext.newPage();
|
const page = await stateContext.newPage();
|
||||||
@@ -494,8 +560,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
||||||
await state.waitFor();
|
await state.waitFor();
|
||||||
assert((await state.textContent())?.includes(stateCase.text));
|
assert((await state.textContent())?.includes(stateCase.text));
|
||||||
|
if (stateCase.id === "stale-subject") {
|
||||||
|
assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1);
|
||||||
|
}
|
||||||
|
if (stateCase.id === "failed-subject") {
|
||||||
|
assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1);
|
||||||
|
}
|
||||||
if (stateCase.id !== "empty-subject") {
|
if (stateCase.id !== "empty-subject") {
|
||||||
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
|
assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0);
|
||||||
}
|
}
|
||||||
await assertNoPageWideOverflow(page);
|
await assertNoPageWideOverflow(page);
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|||||||
@@ -122,9 +122,33 @@
|
|||||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||||
"ready": {
|
"ready": {
|
||||||
"kind": "selector",
|
"kind": "selector",
|
||||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
"capturePoints": [
|
||||||
|
{ "id": "initial", "label": "Ready subject" },
|
||||||
|
{
|
||||||
|
"id": "subject-diagnostics",
|
||||||
|
"label": "Subject diagnostics",
|
||||||
|
"interaction": [
|
||||||
|
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||||
|
],
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "details.subject-technical-details[open]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "surface-diagnostics",
|
||||||
|
"label": "Surface sources and diagnostics",
|
||||||
|
"interaction": [
|
||||||
|
{ "action": "click", "selector": "details.surface-details summary" }
|
||||||
|
],
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "details.surface-details[open]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "detail",
|
"id": "detail",
|
||||||
@@ -150,6 +174,18 @@
|
|||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ungenerated",
|
||||||
|
"label": "Ungenerated subject context",
|
||||||
|
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||||
|
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||||
|
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "stale",
|
"id": "stale",
|
||||||
"label": "Stale subject surface",
|
"label": "Stale subject surface",
|
||||||
|
|||||||
@@ -122,9 +122,33 @@
|
|||||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||||
"ready": {
|
"ready": {
|
||||||
"kind": "selector",
|
"kind": "selector",
|
||||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']"
|
||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
"capturePoints": [
|
||||||
|
{ "id": "initial", "label": "Ready subject" },
|
||||||
|
{
|
||||||
|
"id": "subject-diagnostics",
|
||||||
|
"label": "Subject diagnostics",
|
||||||
|
"interaction": [
|
||||||
|
{ "action": "click", "selector": "details.subject-technical-details summary" }
|
||||||
|
],
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "details.subject-technical-details[open]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "surface-diagnostics",
|
||||||
|
"label": "Surface sources and diagnostics",
|
||||||
|
"interaction": [
|
||||||
|
{ "action": "click", "selector": "details.surface-details summary" }
|
||||||
|
],
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "details.surface-details[open]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "detail",
|
"id": "detail",
|
||||||
@@ -150,6 +174,18 @@
|
|||||||
},
|
},
|
||||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ungenerated",
|
||||||
|
"label": "Ungenerated subject context",
|
||||||
|
"path": "/w/memory-review/memory/ungenerated-subject",
|
||||||
|
"goal": "Distinguish a Subject with no generated resident context from lost committed Memory.",
|
||||||
|
"dataState": "Ungenerated resident context and a successful empty committed Memory list.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "[data-memory-view='subject'][data-memory-state='ungenerated']"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Not generated" }]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "stale",
|
"id": "stale",
|
||||||
"label": "Stale subject surface",
|
"label": "Stale subject surface",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
|
|||||||
maxIdentifierBytes: 512,
|
maxIdentifierBytes: 512,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const SUBJEKTIV_API_LIMITS = {
|
||||||
|
maxBehaviorBytes: 16384,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
|
|
||||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||||
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
|
|||||||
|
|
||||||
export type SubjektivSubjectState = "active" | "retired";
|
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, };
|
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896)
|
// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
parseWorkerLaunchOptionsResponse,
|
parseWorkerLaunchOptionsResponse,
|
||||||
parseWorkerSummary,
|
parseWorkerSummary,
|
||||||
} from "./workers.ts";
|
} from "./workers.ts";
|
||||||
|
import { loadJson } from "./http.ts";
|
||||||
|
|
||||||
const worker = {
|
const worker = {
|
||||||
runtime_id: "runtime-a",
|
runtime_id: "runtime-a",
|
||||||
@@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
|
|||||||
assertEquals(observedStopped.state, "stopped");
|
assertEquals(observedStopped.state, "stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const workerStates = [
|
||||||
|
{ kind: "idle" },
|
||||||
|
{ kind: "busy", state: { kind: "run", state: "running" } },
|
||||||
|
{ kind: "busy", state: { kind: "run", state: "pausing" } },
|
||||||
|
{ kind: "busy", state: { kind: "run", state: "paused" } },
|
||||||
|
{ kind: "busy", state: { kind: "run", state: "cancelling" } },
|
||||||
|
{ kind: "busy", state: { kind: "maintenance", state: "compacting" } },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
Deno.test("Worker summary parser preserves the optional submission completion fence in every state", () => {
|
||||||
|
for (const state of workerStates) {
|
||||||
|
for (const completion of [
|
||||||
|
{},
|
||||||
|
{ last_finished_submission_request_id: null },
|
||||||
|
{ last_finished_submission_request_id: "completed-submit" },
|
||||||
|
]) {
|
||||||
|
const snapshot = { last_command_id: 7, ...completion, state };
|
||||||
|
const parsed = parseWorkerSummary({
|
||||||
|
...worker,
|
||||||
|
resource_key: "W-1",
|
||||||
|
availability: "observed",
|
||||||
|
worker_state: snapshot,
|
||||||
|
});
|
||||||
|
assertEquals(parsed.worker_state, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker summary parser validates submission completion fences without accepting unknown fields", () => {
|
||||||
|
for (const state of workerStates) {
|
||||||
|
for (const invalid of [0, false, {}, [], "x".repeat(65_537)]) {
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkerSummary({
|
||||||
|
...worker,
|
||||||
|
resource_key: "W-1",
|
||||||
|
availability: "observed",
|
||||||
|
worker_state: {
|
||||||
|
last_command_id: 7,
|
||||||
|
last_finished_submission_request_id: invalid,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"worker_state.last_finished_submission_request_id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkerSummary({
|
||||||
|
...worker,
|
||||||
|
resource_key: "W-1",
|
||||||
|
availability: "observed",
|
||||||
|
worker_state: {
|
||||||
|
last_command_id: 7,
|
||||||
|
last_finished_submission_request_id: "completed-submit",
|
||||||
|
state,
|
||||||
|
unexpected: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Error,
|
||||||
|
"unknown field unexpected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Console Worker API loading accepts a completed submission snapshot", async () => {
|
||||||
|
const snapshot = {
|
||||||
|
last_command_id: 0,
|
||||||
|
last_finished_submission_request_id: "completed-submit",
|
||||||
|
state: { kind: "idle" },
|
||||||
|
};
|
||||||
|
const result = await loadJson(
|
||||||
|
() => Promise.resolve(Response.json({
|
||||||
|
...worker,
|
||||||
|
resource_key: "W-1",
|
||||||
|
availability: "observed",
|
||||||
|
worker_state: snapshot,
|
||||||
|
})),
|
||||||
|
"/api/w/workspace-a/workers/W-1",
|
||||||
|
undefined,
|
||||||
|
parseWorkerSummary,
|
||||||
|
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
assertEquals(result.error, null);
|
||||||
|
assertEquals(result.data?.worker_state, snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
|
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
alias: "sessions",
|
alias: "sessions",
|
||||||
|
|||||||
@@ -262,7 +262,16 @@ function workerStateSnapshot(
|
|||||||
label: string,
|
label: string,
|
||||||
): WorkerStateSnapshot {
|
): WorkerStateSnapshot {
|
||||||
const snapshot = record(value, label);
|
const snapshot = record(value, label);
|
||||||
exact(snapshot, ["last_command_id", "state"], label);
|
exact(
|
||||||
|
snapshot,
|
||||||
|
["last_command_id", "last_finished_submission_request_id", "state"],
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
const lastFinishedSubmissionRequestId = optional(
|
||||||
|
snapshot.last_finished_submission_request_id,
|
||||||
|
`${label}.last_finished_submission_request_id`,
|
||||||
|
string,
|
||||||
|
);
|
||||||
const state = record(snapshot.state, `${label}.state`);
|
const state = record(snapshot.state, `${label}.state`);
|
||||||
const kind = string(state.kind, `${label}.state.kind`);
|
const kind = string(state.kind, `${label}.state.kind`);
|
||||||
if (kind === "idle") {
|
if (kind === "idle") {
|
||||||
@@ -272,6 +281,7 @@ function workerStateSnapshot(
|
|||||||
snapshot.last_command_id,
|
snapshot.last_command_id,
|
||||||
`${label}.last_command_id`,
|
`${label}.last_command_id`,
|
||||||
),
|
),
|
||||||
|
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||||
state: { kind: "idle" },
|
state: { kind: "idle" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -291,6 +301,7 @@ function workerStateSnapshot(
|
|||||||
snapshot.last_command_id,
|
snapshot.last_command_id,
|
||||||
`${label}.last_command_id`,
|
`${label}.last_command_id`,
|
||||||
),
|
),
|
||||||
|
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||||
state: {
|
state: {
|
||||||
kind: "busy",
|
kind: "busy",
|
||||||
state: busyKind === "run"
|
state: busyKind === "run"
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
|
||||||
|
|
||||||
|
const toolchain = vi.hoisted(() => ({
|
||||||
|
construct: vi.fn(),
|
||||||
|
setSnapshot: vi.fn(),
|
||||||
|
analyze: vi.fn(),
|
||||||
|
complete: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("./toolchain.ts", () => ({
|
||||||
|
ConfigSourceToolchain: class {
|
||||||
|
constructor() {
|
||||||
|
toolchain.construct();
|
||||||
|
}
|
||||||
|
setSnapshot = toolchain.setSnapshot;
|
||||||
|
analyze = toolchain.analyze;
|
||||||
|
complete = toolchain.complete;
|
||||||
|
close = toolchain.close;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const schemaSource = "schema body\n".repeat(6000);
|
||||||
|
const tree = {
|
||||||
|
snapshot: {
|
||||||
|
revision: 7,
|
||||||
|
digest: "sha256:tree",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content: "WorkspaceConfigSchema({})\n",
|
||||||
|
content_digest: "sha256:main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
contract_version: 1,
|
||||||
|
decodal_version: "0.4.0",
|
||||||
|
schema_version: 1,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
import_policy_version: 1,
|
||||||
|
schema_bundle: {
|
||||||
|
contributions: [{
|
||||||
|
provider_id: "builtin",
|
||||||
|
namespace: "workspace",
|
||||||
|
version: "1",
|
||||||
|
source: schemaSource,
|
||||||
|
source_digest: "sha256:contribution",
|
||||||
|
}],
|
||||||
|
source: schemaSource,
|
||||||
|
fingerprint: "sha256:schema",
|
||||||
|
},
|
||||||
|
fingerprint: "sha256:toolchain",
|
||||||
|
},
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
};
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
const response = () => new Response(JSON.stringify(tree));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
fetcher.mockImplementation(async () => response());
|
||||||
|
toolchain.setSnapshot.mockResolvedValue(undefined);
|
||||||
|
toolchain.analyze.mockResolvedValue([]);
|
||||||
|
toolchain.complete.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEditor(workspaceId = "w") {
|
||||||
|
return render(ConfigSourceEditor, { workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoEditor(view: ReturnType<typeof renderEditor>) {
|
||||||
|
expect(view.queryByText("Select or create a source")).toBeNull();
|
||||||
|
expect(view.queryByRole("button", { name: "Create local source" }))
|
||||||
|
.toBeNull();
|
||||||
|
expect(view.queryByRole("button", { name: "Commit" })).toBeNull();
|
||||||
|
expect(view.container.querySelector(".cm-editor")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectReady(view: ReturnType<typeof renderEditor>) {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not
|
||||||
|
.toBeNull()
|
||||||
|
);
|
||||||
|
expect(view.container.querySelector('.cm-content[contenteditable="true"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(view.container.textContent).toContain("WorkspaceConfigSchema({})");
|
||||||
|
expect(view.queryByRole("alert")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loading is not an empty source tree; editing waits for schema installation", async () => {
|
||||||
|
let finishFetch!: (response: Response) => void;
|
||||||
|
let finishSetup!: () => void;
|
||||||
|
fetcher.mockReturnValueOnce(
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
finishFetch = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
toolchain.setSnapshot.mockReturnValueOnce(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishSetup = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = render(ConfigSourceEditor, { workspaceId: "w/one" });
|
||||||
|
expect(view.getByRole("status").textContent).toContain("Loading source tree");
|
||||||
|
expectNoEditor(view);
|
||||||
|
finishFetch(response());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toolchain.setSnapshot).toHaveBeenCalledWith(
|
||||||
|
tree.snapshot,
|
||||||
|
tree.contract.schema_bundle,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
finishSetup();
|
||||||
|
await expectReady(view);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
|
"/api/w/w%2Fone/config/source-tree",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["http", "invalid"])(
|
||||||
|
"%s failure has a manual reload, not creation controls or automatic retries",
|
||||||
|
async (failure) => {
|
||||||
|
fetcher.mockResolvedValueOnce(
|
||||||
|
failure === "http"
|
||||||
|
? new Response("Backend unavailable", { status: 503 })
|
||||||
|
: new Response(JSON.stringify({ ...tree, unexpected: true })),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
"Unable to load workspace configuration",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
failure === "http" ? "Backend unavailable" : "invalid response",
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
expect(toolchain.setSnapshot).not.toHaveBeenCalled();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce();
|
||||||
|
await fireEvent.click(
|
||||||
|
view.getByRole("button", { name: "Reload source tree" }),
|
||||||
|
);
|
||||||
|
await expectReady(view);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(["construct", "setSnapshot"] as const)(
|
||||||
|
"toolchain %s failure is caught and manual reload initializes a fresh toolchain",
|
||||||
|
async (phase) => {
|
||||||
|
if (phase === "construct") {
|
||||||
|
toolchain.construct.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Worker unavailable");
|
||||||
|
});
|
||||||
|
} else {toolchain.setSnapshot.mockRejectedValueOnce(
|
||||||
|
new Error("Schema setup failed"),
|
||||||
|
);}
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
phase === "construct" ? "Worker unavailable" : "Schema setup failed",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
await fireEvent.click(
|
||||||
|
view.getByRole("button", { name: "Reload source tree" }),
|
||||||
|
);
|
||||||
|
await expectReady(view);
|
||||||
|
expect(toolchain.construct).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a successfully loaded empty tree still offers source creation", async () => {
|
||||||
|
fetcher.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({
|
||||||
|
...tree,
|
||||||
|
snapshot: { ...tree.snapshot, entries: {} },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByText("Select or create a source")).not.toBeNull()
|
||||||
|
);
|
||||||
|
expect(view.getByRole("button", { name: "Create local source" })).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(view.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during schema setup closes the toolchain", async () => {
|
||||||
|
let failSetup!: (error: Error) => void;
|
||||||
|
toolchain.setSnapshot.mockReturnValueOnce(
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
failSetup = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
toolchain.close.mockImplementationOnce(() =>
|
||||||
|
failSetup(new Error("toolchain closed"))
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce());
|
||||||
|
view.unmount();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(toolchain.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during fetch does not start a toolchain", async () => {
|
||||||
|
let finishFetch!: (response: Response) => void;
|
||||||
|
fetcher.mockReturnValueOnce(
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
finishFetch = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
view.unmount();
|
||||||
|
finishFetch(response());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(toolchain.construct).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
let newPath = $state("module.dcdl");
|
let newPath = $state("module.dcdl");
|
||||||
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||||
let status = $state("Loading source tree…");
|
let status = $state("Loading source tree…");
|
||||||
|
let loadState = $state<"loading" | "error" | "ready">("loading");
|
||||||
|
let mounted = false;
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||||
let baseRevision = $state(0);
|
let baseRevision = $state(0);
|
||||||
@@ -42,9 +44,12 @@
|
|||||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
toolchain = new ConfigSourceToolchain();
|
mounted = true;
|
||||||
void reload();
|
void reload();
|
||||||
return () => toolchain?.close();
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
toolchain?.close();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -70,9 +75,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
loadState = "loading";
|
||||||
|
status = "Loading source tree…";
|
||||||
analysisReady = false;
|
analysisReady = false;
|
||||||
|
analysisGeneration += 1;
|
||||||
try {
|
try {
|
||||||
treeState = await fetchConfigTree(workspaceId);
|
const remote = await fetchConfigTree(workspaceId);
|
||||||
|
if (!mounted) return;
|
||||||
|
toolchain?.close();
|
||||||
|
toolchain = null;
|
||||||
|
toolchain = new ConfigSourceToolchain();
|
||||||
|
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||||
|
if (!mounted) return;
|
||||||
|
// Publish the new baseline only after both fetch and toolchain setup succeed.
|
||||||
|
treeState = remote;
|
||||||
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||||
}
|
}
|
||||||
@@ -80,15 +96,19 @@
|
|||||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||||
baseRevision = treeState.snapshot.revision;
|
baseRevision = treeState.snapshot.revision;
|
||||||
baseDigest = treeState.snapshot.digest;
|
baseDigest = treeState.snapshot.digest;
|
||||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
|
||||||
analysisReady = true;
|
analysisReady = true;
|
||||||
workingChanges = [];
|
workingChanges = [];
|
||||||
renamePath = selectedPath;
|
renamePath = selectedPath;
|
||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
conflict = false;
|
conflict = false;
|
||||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||||
|
loadState = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
toolchain?.close();
|
||||||
|
toolchain = null;
|
||||||
status = String(error);
|
status = String(error);
|
||||||
|
loadState = "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +233,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discardAndReload() {
|
async function discardAndReload() {
|
||||||
workingChanges = [];
|
|
||||||
source = "";
|
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +305,18 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="config-source-shell" aria-label="Workspace configuration source tree">
|
<section class="config-source-shell" aria-label="Workspace configuration source tree" aria-busy={loadState === "loading"}>
|
||||||
|
{#if loadState === "loading"}
|
||||||
|
<div class="config-source-load" role="status">Loading source tree…</div>
|
||||||
|
{:else if loadState === "error"}
|
||||||
|
<div class="config-source-load">
|
||||||
|
<div role="alert">
|
||||||
|
<strong>Unable to load workspace configuration</strong>
|
||||||
|
<p>{status}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick={reload}>Reload source tree</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<aside class="config-source-tree">
|
<aside class="config-source-tree">
|
||||||
<div class="config-source-tree__header">
|
<div class="config-source-tree__header">
|
||||||
<strong>Source tree</strong>
|
<strong>Source tree</strong>
|
||||||
@@ -354,4 +383,12 @@
|
|||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-source-load {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
// Schema bodies are documents, not short metadata. Keep them within the
|
||||||
|
// response budget; readJson also bounds the combined bundle and contributions.
|
||||||
|
const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES;
|
||||||
const MAX_ERROR_BYTES = 4 * 1024;
|
const MAX_ERROR_BYTES = 4 * 1024;
|
||||||
const MAX_ENTRY_COUNT = 256;
|
const MAX_ENTRY_COUNT = 256;
|
||||||
const MAX_ENTRY_BYTES = 256 * 1024;
|
const MAX_ENTRY_BYTES = 256 * 1024;
|
||||||
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
|||||||
provider_id: boundedString(item.provider_id),
|
provider_id: boundedString(item.provider_id),
|
||||||
namespace: boundedString(item.namespace),
|
namespace: boundedString(item.namespace),
|
||||||
version: boundedString(item.version),
|
version: boundedString(item.version),
|
||||||
source: boundedString(item.source),
|
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
||||||
};
|
};
|
||||||
@@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle {
|
|||||||
const item = record(value, ["contributions", "source", "fingerprint"]);
|
const item = record(value, ["contributions", "source", "fingerprint"]);
|
||||||
return {
|
return {
|
||||||
contributions: boundedArray(item.contributions).map(parseContribution),
|
contributions: boundedArray(item.contributions).map(parseContribution),
|
||||||
source: boundedString(item.source),
|
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||||
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte";
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
test("the three-dot Details control invokes the existing toggle and reflects its state", async () => {
|
||||||
|
let toggles = 0;
|
||||||
|
const view = render(WorkerContextStatus, {
|
||||||
|
metadata: null,
|
||||||
|
detailsOpen: false,
|
||||||
|
onToggleDetails: () => { toggles++; },
|
||||||
|
});
|
||||||
|
const button = view.getByRole("button", { name: "Details" });
|
||||||
|
expect(button.querySelectorAll("circle")).toHaveLength(3);
|
||||||
|
expect(button.textContent?.trim()).toBe("");
|
||||||
|
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
button.click();
|
||||||
|
expect(toggles).toBe(1);
|
||||||
|
await view.rerender({ detailsOpen: true });
|
||||||
|
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
test("renders resolved model, reasoning, and measured context accessibly", () => {
|
test("renders resolved model, reasoning, and measured context accessibly", () => {
|
||||||
const view = render(WorkerContextStatus, {
|
const view = render(WorkerContextStatus, {
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () =>
|
|||||||
|
|
||||||
const status = view.getByLabelText("Worker model and context");
|
const status = view.getByLabelText("Worker model and context");
|
||||||
expect(status.textContent).toContain("gpt-6-astra · high");
|
expect(status.textContent).toContain("gpt-6-astra · high");
|
||||||
expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)");
|
expect(status.textContent).toContain("142.0k / 272.0k (52%)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
|
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
|
||||||
const view = render(WorkerContextStatus, { metadata: null });
|
const view = render(WorkerContextStatus, { metadata: null });
|
||||||
expect(view.getByText("Model unavailable")).not.toBeNull();
|
expect(view.getByText("Model unavailable")).not.toBeNull();
|
||||||
expect(view.getByText("Context unavailable")).not.toBeNull();
|
expect(view.getByText("unavailable")).not.toBeNull();
|
||||||
expect(view.container.textContent).not.toContain("0%");
|
expect(view.container.textContent).not.toContain("0%");
|
||||||
|
|
||||||
await view.rerender({
|
await view.rerender({
|
||||||
@@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(view.container.textContent).toContain("claude · 8.2k token budget");
|
expect(view.container.textContent).toContain("claude · 8.2k token budget");
|
||||||
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
|
expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)");
|
||||||
|
|
||||||
await view.rerender({
|
await view.rerender({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
import type { ConsoleWorkerMetadata } from "./model";
|
import type { ConsoleWorkerMetadata } from "./model";
|
||||||
import {
|
import {
|
||||||
formatContextSummary,
|
formatContextSummary,
|
||||||
@@ -7,23 +8,51 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
metadata: ConsoleWorkerMetadata | null;
|
metadata: ConsoleWorkerMetadata | null;
|
||||||
|
controls?: Snippet;
|
||||||
|
detailsOpen?: boolean;
|
||||||
|
onToggleDetails?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { metadata }: Props = $props();
|
let { metadata, controls, detailsOpen = false, onToggleDetails }: Props = $props();
|
||||||
const modelSummary = $derived(formatModelSummary(metadata));
|
const modelSummary = $derived(formatModelSummary(metadata));
|
||||||
const contextSummary = $derived(formatContextSummary(metadata));
|
const contextSummary = $derived(formatContextSummary(metadata));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="worker-context-status" aria-label="Worker model and context">
|
<div class="worker-context-status" aria-label="Worker model and context">
|
||||||
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
|
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
|
||||||
<span class="separator" aria-hidden="true">|</span>
|
<span class="separator" aria-hidden="true">·</span>
|
||||||
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
|
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
|
||||||
|
{#if controls || onToggleDetails}
|
||||||
|
<div class="worker-context-actions">
|
||||||
|
{@render controls?.()}
|
||||||
|
{#if onToggleDetails}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="details-button"
|
||||||
|
aria-label="Details"
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
onclick={onToggleDetails}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="2" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
<circle cx="19" cy="12" r="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.worker-context-status {
|
.worker-context-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -50,20 +79,44 @@
|
|||||||
color: var(--line-strong);
|
color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.worker-context-actions {
|
||||||
.worker-context-status {
|
display: flex;
|
||||||
width: 100%;
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
row-gap: 0;
|
gap: var(--space-2);
|
||||||
}
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.separator {
|
.details-button {
|
||||||
display: none;
|
display: inline-flex;
|
||||||
}
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--space-5);
|
||||||
|
height: var(--space-5);
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--space-1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.worker-model,
|
.details-button:hover,
|
||||||
.worker-context {
|
.details-button[aria-expanded="true"] {
|
||||||
flex: 1 1 100%;
|
background: var(--bg-subtle);
|
||||||
}
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-button:focus-visible {
|
||||||
|
color: var(--text-strong);
|
||||||
|
outline: 1px solid currentColor;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3769,6 +3769,29 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("image rejection correction updates the existing tool result without duplicate rows", () => {
|
||||||
|
const result: SessionSnapshotEntry = {
|
||||||
|
kind: "tool_result", entry_id: "result-image", timestamp: 2,
|
||||||
|
provenance: "tool_output", call_id: "image", summary: "Attached image", is_error: false,
|
||||||
|
attachments: [{ attachment_id: "image-body", media_type: "image/png", byte_len: 100 }],
|
||||||
|
};
|
||||||
|
const lines = projectConsole([
|
||||||
|
{ eventId: "call", event: { event: "session_entry_committed", data: { entry: {
|
||||||
|
kind: "tool_call", entry_id: "call-image", timestamp: 1, provenance: "model_output",
|
||||||
|
call_id: "image", name: "ViewImage", arguments: "{}",
|
||||||
|
} } } },
|
||||||
|
{ eventId: "original", event: { event: "session_entry_committed", data: { entry: result } } },
|
||||||
|
{ eventId: "correction", event: { event: "session_entry_committed", data: { entry: {
|
||||||
|
...result, timestamp: 3, summary: "Resize or crop and retry ViewImage", is_error: true, attachments: [],
|
||||||
|
} } } },
|
||||||
|
]).lines;
|
||||||
|
assertEquals(lines.length, 1);
|
||||||
|
assertEquals(lines[0].entryId, "result-image");
|
||||||
|
assertEquals(lines[0].toolCall?.state, "error");
|
||||||
|
assertEquals(lines[0].attachments ?? [], []);
|
||||||
|
assert(lines[0].body.includes("Resize or crop"), "show the model recovery instruction");
|
||||||
|
});
|
||||||
|
|
||||||
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||||
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
|
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
|
||||||
kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1,
|
kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
|||||||
contextTokens: 142_000,
|
contextTokens: 142_000,
|
||||||
contextSource: "estimated",
|
contextSource: "estimated",
|
||||||
}),
|
}),
|
||||||
"Context ~142.0k / 272.0k (52%)",
|
"~142.0k / 272.0k (52%)",
|
||||||
);
|
);
|
||||||
assertEquals(formatModelSummary(null), "Model unavailable");
|
assertEquals(formatModelSummary(null), "Model unavailable");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
|||||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
||||||
"reasoning unavailable",
|
"reasoning unavailable",
|
||||||
);
|
);
|
||||||
assertEquals(formatContextSummary(null), "Context unavailable");
|
assertEquals(formatContextSummary(null), "unavailable");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
formatContextSummary({
|
formatContextSummary({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
|||||||
contextTokens: 0,
|
contextTokens: 0,
|
||||||
contextSource: null,
|
contextSource: null,
|
||||||
}),
|
}),
|
||||||
"Context unavailable",
|
"unavailable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
|||||||
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
||||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||||
subjectIndexPage.includes("subject.store_revision") &&
|
subjectIndexPage.includes("Worker connection") &&
|
||||||
|
subjectIndexPage.includes("subject.current_worker") &&
|
||||||
|
!subjectIndexPage.includes("subject.store_revision") &&
|
||||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||||
"Memory product entry should list explicit typed subjects",
|
"Memory product entry should list explicit typed subjects",
|
||||||
);
|
);
|
||||||
@@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
|||||||
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
||||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||||
subjectPage.includes("DocumentMarkdown from") &&
|
subjectPage.includes("DocumentMarkdown from") &&
|
||||||
subjectPage.includes("Resident surface") &&
|
subjectPage.includes("Resident context") &&
|
||||||
|
subjectPage.includes("Subject store revision") &&
|
||||||
subjectPage.includes("Current Memories") &&
|
subjectPage.includes("Current Memories") &&
|
||||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||||
|
subjectPage.includes("surface?.availability === 'ungenerated'") &&
|
||||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||||
|
subjectPage.includes("surface?.availability === 'failed'") &&
|
||||||
subjectPage.includes("data-surface-ready-empty") &&
|
subjectPage.includes("data-surface-ready-empty") &&
|
||||||
!subjectPage.includes("overflow-y"),
|
!subjectPage.includes("overflow-y"),
|
||||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
||||||
@@ -738,6 +743,32 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Model information stays below Composer in one compact monospace row", async () => {
|
||||||
|
const page = await Deno.readTextFile(new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
));
|
||||||
|
const composer = page.indexOf('<form class="console-composer"');
|
||||||
|
const composerEnd = page.indexOf("</form>", composer);
|
||||||
|
const status = page.indexOf("<WorkerContextStatus");
|
||||||
|
assert(composer >= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer");
|
||||||
|
assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata");
|
||||||
|
assert(page.indexOf("<WorkerContextStatus", status + 1) === -1, "Render model information only once");
|
||||||
|
const component = await Deno.readTextFile(new URL("./WorkerContextStatus.svelte", import.meta.url));
|
||||||
|
for (const rule of [
|
||||||
|
"flex-wrap: nowrap", "white-space: nowrap", "text-overflow: ellipsis",
|
||||||
|
"font-family: var(--font-mono)", "font-size: var(--font-size-compact)",
|
||||||
|
"line-height: var(--line-height-compact)", "padding-inline: var(--space-3)",
|
||||||
|
]) assert(component.includes(rule), rule);
|
||||||
|
assert(!component.includes("@media"), "Narrow layouts must retain the same single row");
|
||||||
|
const modes = page.indexOf('class="console-view-toggle"');
|
||||||
|
assert(modes > status && modes < page.indexOf("</WorkerContextStatus>", status), "Display toggle must be in the model information row");
|
||||||
|
assert(!page.includes("console-header"), "Remove the empty header and its styles");
|
||||||
|
assert(component.indexOf("{@render controls?.()}") < component.indexOf('class="details-button"'), "Display controls must precede Details");
|
||||||
|
assert(/\.worker-context-actions\s*\{[^}]*flex: 0 0 auto/.test(component), "Keep display controls from shrinking on narrow screens");
|
||||||
|
assert(component.includes("margin-left: auto"), "Align footer controls to the right");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
||||||
const files = [
|
const files = [
|
||||||
"ComposerInput.svelte",
|
"ComposerInput.svelte",
|
||||||
@@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
|
|||||||
const input = await Deno.readTextFile(
|
const input = await Deno.readTextFile(
|
||||||
new URL("./ComposerInput.svelte", import.meta.url),
|
new URL("./ComposerInput.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const header = page.slice(
|
|
||||||
page.indexOf('<section class="console-header'),
|
|
||||||
page.indexOf("{#if rewindTargets.length"),
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
!header.includes("Compact") && !header.includes("Rewind") &&
|
!page.includes('class="console-header') &&
|
||||||
!page.includes("requestRewindTargets"),
|
!page.includes("requestRewindTargets"),
|
||||||
"Header must defer Compact/Rewind to commands",
|
"Compact/Rewind must remain commands without a dedicated header",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
input.includes('role="listbox"') && input.includes('role="option"') &&
|
input.includes('role="listbox"') && input.includes('role="option"') &&
|
||||||
@@ -1588,12 +1615,8 @@ Deno.test("mini task summary owns pane toggling instead of the header", async ()
|
|||||||
const component = await Deno.readTextFile(
|
const component = await Deno.readTextFile(
|
||||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const header = page.slice(
|
|
||||||
page.indexOf('<section class="console-header'),
|
|
||||||
page.indexOf("{#if rewindTargets.length"),
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
!header.includes("taskPaneOpen = !taskPaneOpen") &&
|
!page.includes('class="console-header') &&
|
||||||
page.includes("onTogglePane={() => {"),
|
page.includes("onTogglePane={() => {"),
|
||||||
"Only the mini summary should own Tasks toggling",
|
"Only the mini summary should own Tasks toggling",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export function formatContextSummary(
|
|||||||
used === null || used === undefined || limit === null ||
|
used === null || used === undefined || limit === null ||
|
||||||
limit === undefined || !Number.isSafeInteger(used) ||
|
limit === undefined || !Number.isSafeInteger(used) ||
|
||||||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
||||||
) return "Context unavailable";
|
) return "unavailable";
|
||||||
const percent = Math.round((used / limit) * 100);
|
const percent = Math.round((used / limit) * 100);
|
||||||
if (!Number.isFinite(percent)) return "Context unavailable";
|
if (!Number.isFinite(percent)) return "unavailable";
|
||||||
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
|
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
|
||||||
return `Context ${estimate}${formatRunTokens(used)} / ${
|
return `${estimate}${formatRunTokens(used)} / ${
|
||||||
formatRunTokens(limit)
|
formatRunTokens(limit)
|
||||||
} (${percent}%)`;
|
} (${percent}%)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
|
import {
|
||||||
|
MEMORY_API_LIMITS,
|
||||||
|
SUBJEKTIV_API_LIMITS,
|
||||||
|
} from "#lib/generated/memory-api.ts";
|
||||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import type {
|
import type {
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -26,6 +29,7 @@ import type {
|
|||||||
SubjektivResidentSurfaceAvailability,
|
SubjektivResidentSurfaceAvailability,
|
||||||
SubjektivResidentSurfaceResponse,
|
SubjektivResidentSurfaceResponse,
|
||||||
SubjektivResidentSurfaceSnapshot,
|
SubjektivResidentSurfaceSnapshot,
|
||||||
|
SubjektivSubjectBehaviorUpdateRequest,
|
||||||
SubjektivSubjectCreateRequest,
|
SubjektivSubjectCreateRequest,
|
||||||
SubjektivSubjectListResponse,
|
SubjektivSubjectListResponse,
|
||||||
SubjektivSubjectResponse,
|
SubjektivSubjectResponse,
|
||||||
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const MAX_SUBJECT_ROLE_BYTES = 256;
|
const MAX_SUBJECT_ROLE_BYTES = 256;
|
||||||
|
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
|
||||||
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
export type SubjektivSubjectCreateErrorKind =
|
export type SubjektivSubjectCreateErrorKind =
|
||||||
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
|
|||||||
"Subject role must be at most 256 bytes and contain no control characters.",
|
"Subject role must be at most 256 bytes and contain no control characters.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { role: request.role };
|
const behaviorMd = request.behavior_md ?? "";
|
||||||
|
validateSubjectBehavior(behaviorMd);
|
||||||
|
return { role: request.role, behavior_md: behaviorMd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSubjectBehavior(behaviorMd: string): void {
|
||||||
|
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
|
||||||
|
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"validation",
|
||||||
|
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"validation",
|
||||||
|
"Subject behavior must be empty or contain non-whitespace text.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"validation",
|
||||||
|
"Subject behavior contains an unsupported control character.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSubjektivSubject(
|
export async function createSubjektivSubject(
|
||||||
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
|
|||||||
const subject = parseSubjektivSubjectResponse(
|
const subject = parseSubjektivSubjectResponse(
|
||||||
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||||
);
|
);
|
||||||
if (subject.role !== validated.role) {
|
if (
|
||||||
throw new Error("created Subject role does not match the request");
|
subject.role !== validated.role ||
|
||||||
|
subject.behavior_md !== validated.behavior_md
|
||||||
|
) {
|
||||||
|
throw new Error("created Subject does not match the request");
|
||||||
}
|
}
|
||||||
return subject;
|
return subject;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSubjektivSubjectBehavior(
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
workspaceId: string,
|
||||||
|
subjectId: string,
|
||||||
|
request: SubjektivSubjectBehaviorUpdateRequest,
|
||||||
|
): Promise<SubjektivSubjectResponse> {
|
||||||
|
validateSubjectBehavior(request.behavior_md);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(request.expected_behavior_revision) ||
|
||||||
|
request.expected_behavior_revision < 0
|
||||||
|
) {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"validation",
|
||||||
|
"Subject behavior revision is invalid.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchFn(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"unknown_outcome",
|
||||||
|
"The save outcome is unknown. Reload the Subject before retrying.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await subjektivSubjectCreateErrorMessage(response);
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
response.status >= 500 ? "unknown_outcome" : "rejected",
|
||||||
|
response.status === 409
|
||||||
|
? "The behavior changed elsewhere. Reload the Subject and review the latest text."
|
||||||
|
: message,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const subject = parseSubjektivSubjectResponse(
|
||||||
|
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
|
||||||
|
subjectId,
|
||||||
|
);
|
||||||
|
if (subject.behavior_md !== request.behavior_md) {
|
||||||
|
throw new Error("saved Subject behavior does not match the request");
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
} catch {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"unknown_outcome",
|
||||||
|
"The behavior may have been saved, but the response could not be confirmed. Reload the Subject.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function subjektivSubjectCreateErrorMessage(
|
async function subjektivSubjectCreateErrorMessage(
|
||||||
response: Response,
|
response: Response,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
|
|||||||
} catch {
|
} catch {
|
||||||
// A rejected request still has a known outcome even when its body is invalid.
|
// A rejected request still has a known outcome even when its body is invalid.
|
||||||
}
|
}
|
||||||
return `Subject creation failed with HTTP ${response.status}.`;
|
return `Subject request failed with HTTP ${response.status}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMemoryDocumentResponse(
|
export function parseMemoryDocumentResponse(
|
||||||
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
|
|||||||
[
|
[
|
||||||
"id",
|
"id",
|
||||||
"role",
|
"role",
|
||||||
|
"behavior_md",
|
||||||
|
"behavior_revision",
|
||||||
"state",
|
"state",
|
||||||
"store_revision",
|
"store_revision",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
|
|||||||
const result: SubjektivSubjectResponse = {
|
const result: SubjektivSubjectResponse = {
|
||||||
id,
|
id,
|
||||||
role: requiredString(record, "role"),
|
role: requiredString(record, "role"),
|
||||||
|
behavior_md: requiredString(
|
||||||
|
record,
|
||||||
|
"behavior_md",
|
||||||
|
MAX_SUBJECT_BEHAVIOR_BYTES,
|
||||||
|
),
|
||||||
|
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
|
||||||
state,
|
state,
|
||||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||||
created_at: requiredString(record, "created_at"),
|
created_at: requiredString(record, "created_at"),
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CompletionResult,
|
||||||
|
completionStatus,
|
||||||
|
startCompletion,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import DecodalSourceEditor from "./DecodalSourceEditor.svelte";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
value: string,
|
||||||
|
props: {
|
||||||
|
readonly?: boolean;
|
||||||
|
fixedSchemaWrapper?: boolean;
|
||||||
|
onComplete?: (
|
||||||
|
source: string,
|
||||||
|
offset: number,
|
||||||
|
explicit: boolean,
|
||||||
|
) => Promise<CompletionResult | null>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const result = render(DecodalSourceEditor, { value, onChange, ...props });
|
||||||
|
const content = result.container.querySelector<HTMLElement>(".cm-content")!;
|
||||||
|
const editor = EditorView.findFromDOM(content)!;
|
||||||
|
editor.focus();
|
||||||
|
return { ...result, content, editor, onChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" },
|
||||||
|
{
|
||||||
|
name: "object body",
|
||||||
|
value: "{\n name = 1",
|
||||||
|
anchor: 12,
|
||||||
|
expected: "{\n name = 1\n ",
|
||||||
|
},
|
||||||
|
{ name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" },
|
||||||
|
{
|
||||||
|
name: "nested object",
|
||||||
|
value: "{\n nested = {}\n}",
|
||||||
|
anchor: 14,
|
||||||
|
expected: "{\n nested = {\n \n }\n}",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"Enter inserts an indented newline in $name",
|
||||||
|
async ({ value, anchor, expected }) => {
|
||||||
|
const { content, editor, onChange } = setup(value);
|
||||||
|
editor.dispatch({ selection: { anchor } });
|
||||||
|
expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }))
|
||||||
|
.toBe(false);
|
||||||
|
expect(editor.state.doc.toString()).toBe(expected);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Enter indents inside the fixed wrapper without altering its assertion", async () => {
|
||||||
|
const { content, editor } = setup("{} as WorkspaceConfigSchema\n", {
|
||||||
|
fixedSchemaWrapper: true,
|
||||||
|
});
|
||||||
|
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(
|
||||||
|
"{\n \n} as WorkspaceConfigSchema\n",
|
||||||
|
);
|
||||||
|
expect(editor.state.selection.main.head).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enter does not mutate readonly sources", async () => {
|
||||||
|
const value = "{\n name = 1";
|
||||||
|
const { content, editor, onChange } = setup(value, { readonly: true });
|
||||||
|
editor.dispatch({ selection: { anchor: value.length } });
|
||||||
|
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(value);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enter adds an indented newline instead of accepting a visible completion", async () => {
|
||||||
|
const value = "{\n na";
|
||||||
|
const { content, editor } = setup(value, {
|
||||||
|
onComplete: async () => ({
|
||||||
|
from: 4,
|
||||||
|
options: [{ label: "name", type: "property" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
editor.dispatch({ selection: { anchor: value.length } });
|
||||||
|
startCompletion(editor);
|
||||||
|
await waitFor(() => expect(completionStatus(editor.state)).toBe("active"));
|
||||||
|
// Wait past CodeMirror's completion interaction delay, so accepting a
|
||||||
|
// suggestion would be observable rather than ignored as an early keypress.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||||
|
expect(editor.state.doc.toString()).toBe("{\n na\n ");
|
||||||
|
await waitFor(() => expect(completionStatus(editor.state)).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tab indents the current line and Shift+Tab removes indentation", async () => {
|
||||||
|
const { content, editor, onChange } = setup("name = 1");
|
||||||
|
editor.dispatch({ selection: { anchor: 4 } });
|
||||||
|
expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(editor.state.doc.toString()).toBe(" name = 1");
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(" name = 1");
|
||||||
|
expect(editor.hasFocus).toBe(true);
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||||
|
expect(editor.state.doc.toString()).toBe("name = 1");
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith("name = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tab and Shift+Tab indent every selected line", async () => {
|
||||||
|
const value = "first = 1\nsecond = 2";
|
||||||
|
const { content, editor } = setup(value);
|
||||||
|
editor.dispatch({ selection: { anchor: 0, head: value.length } });
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2");
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||||
|
expect(editor.state.doc.toString()).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tab inserts indentation on a blank line", async () => {
|
||||||
|
const { content, editor } = setup("");
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tab and Shift+Tab do not mutate readonly sources", async () => {
|
||||||
|
const value = " name = 1";
|
||||||
|
const { content, editor, onChange } = setup(value, { readonly: true });
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||||
|
expect(editor.state.doc.toString()).toBe(value);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tab indents the schema body without modifying the fixed wrapper", async () => {
|
||||||
|
const value = "{\n name = 1\n} as WorkspaceConfigSchema\n";
|
||||||
|
const { content, editor } = setup(value, { fixedSchemaWrapper: true });
|
||||||
|
editor.dispatch({ selection: { anchor: value.indexOf("name") } });
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(
|
||||||
|
"{\n name = 1\n} as WorkspaceConfigSchema\n",
|
||||||
|
);
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||||
|
expect(editor.state.doc.toString()).toBe(value);
|
||||||
|
editor.dispatch({ selection: { anchor: 0 } });
|
||||||
|
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||||
|
expect(editor.state.doc.toString()).toBe(value);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
type CompletionContext,
|
type CompletionContext,
|
||||||
type CompletionResult,
|
type CompletionResult,
|
||||||
} from '@codemirror/autocomplete';
|
} from '@codemirror/autocomplete';
|
||||||
|
import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
||||||
@@ -129,6 +130,8 @@
|
|||||||
decodal({ highlight: false }),
|
decodal({ highlight: false }),
|
||||||
syntaxHighlighting(syntaxTheme),
|
syntaxHighlighting(syntaxTheme),
|
||||||
autocompletion({
|
autocompletion({
|
||||||
|
// Use the explicit keymap below so Enter never accepts a suggestion.
|
||||||
|
defaultKeymap: false,
|
||||||
activateOnTyping: false,
|
activateOnTyping: false,
|
||||||
override: [
|
override: [
|
||||||
async (context: CompletionContext) => {
|
async (context: CompletionContext) => {
|
||||||
@@ -139,6 +142,10 @@
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
keymap.of(completionKeymapWithoutEnter),
|
keymap.of(completionKeymapWithoutEnter),
|
||||||
|
keymap.of([
|
||||||
|
{ key: 'Enter', run: insertNewlineAndIndent },
|
||||||
|
indentWithTab,
|
||||||
|
]),
|
||||||
fixedSchemaWrapperCompartment.of(
|
fixedSchemaWrapperCompartment.of(
|
||||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, type Snippet } from 'svelte';
|
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||||
|
import { on } from 'svelte/events';
|
||||||
import Bevel from '#lib/workspace/ui/Bevel.svelte';
|
import Bevel from '#lib/workspace/ui/Bevel.svelte';
|
||||||
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
@@ -77,6 +78,19 @@
|
|||||||
focusInside = false;
|
focusInside = false;
|
||||||
clearCloseTimer();
|
clearCloseTimer();
|
||||||
});
|
});
|
||||||
|
function closeAfterNavigation(event: MouseEvent) {
|
||||||
|
if (!mobile || !open || event.defaultPrevented || event.button !== 0 ||
|
||||||
|
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||||
|
if (!(link instanceof HTMLAnchorElement) || !frame.contains(link) ||
|
||||||
|
link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
|
||||||
|
clearCloseTimer();
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nested navigation links, including activation of the current page.
|
||||||
|
// Svelte's listener preserves ordering with delegated child click handlers.
|
||||||
|
onMount(() => on(frame, 'click', closeAfterNavigation));
|
||||||
onDestroy(clearCloseTimer);
|
onDestroy(clearCloseTimer);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/svelte";
|
||||||
|
import { createRawSnippet } from "svelte";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import SidebarFrame from "./SidebarFrame.svelte";
|
||||||
|
|
||||||
|
// Stop happy-dom navigation after the component has handled the bubbling click.
|
||||||
|
const preventBrowserNavigation = (event: MouseEvent) => event.preventDefault();
|
||||||
|
beforeEach(() => document.addEventListener("click", preventBrowserNavigation));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
document.removeEventListener("click", preventBrowserNavigation);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount(mobile = true, mode: "pinned" | "hover" = "pinned") {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
const onModeChange = vi.fn();
|
||||||
|
const children = createRawSnippet(() => ({
|
||||||
|
render: () => `<nav>
|
||||||
|
<a href="/w/example"><span>Workspace home</span></a>
|
||||||
|
<a href="${location.href}">Current page</a>
|
||||||
|
<a href="#section">Section</a>
|
||||||
|
<a href="/account" target="_blank">New tab</a>
|
||||||
|
<a href="/export" download>Download</a>
|
||||||
|
<button type="button">Expand group</button>
|
||||||
|
</nav>`,
|
||||||
|
}));
|
||||||
|
return { ...render(SidebarFrame, {
|
||||||
|
mobile, mode, open: true, children, onOpenChange, onModeChange,
|
||||||
|
}), onOpenChange, onModeChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each(["pinned", "hover"] as const)("mobile %s closes on nested, current-page and hash links", async (mode) => {
|
||||||
|
const view = mount(true, mode);
|
||||||
|
for (const label of ["Workspace home", "Current page", "Section"]) {
|
||||||
|
view.onOpenChange.mockClear();
|
||||||
|
await fireEvent.click(view.getByText(label));
|
||||||
|
expect(view.onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||||
|
expect(view.onModeChange).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile ignores disclosure buttons, modified clicks, new tabs, downloads and cancelled navigation", async () => {
|
||||||
|
const view = mount();
|
||||||
|
for (const label of ["Expand group", "New tab", "Download"]) {
|
||||||
|
await fireEvent.click(view.getByText(label));
|
||||||
|
}
|
||||||
|
const link = view.getByText("Workspace home");
|
||||||
|
for (const options of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
|
||||||
|
await fireEvent.click(link, options);
|
||||||
|
}
|
||||||
|
link.addEventListener("click", (event) => event.preventDefault(), { once: true });
|
||||||
|
await fireEvent.click(link);
|
||||||
|
expect(view.onOpenChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["pinned", "hover"] as const)("desktop %s navigation does not change sidebar mode or open state", async (mode) => {
|
||||||
|
const view = mount(false, mode);
|
||||||
|
await fireEvent.click(view.getByText("Workspace home"));
|
||||||
|
expect(view.onOpenChange).not.toHaveBeenCalled();
|
||||||
|
expect(view.onModeChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||||
let createExpanded = $state(false);
|
let createExpanded = $state(false);
|
||||||
let role = $state('');
|
let role = $state('');
|
||||||
|
let behaviorMd = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let createError = $state<string | null>(null);
|
let createError = $state<string | null>(null);
|
||||||
let roleInvalid = $state(false);
|
let roleInvalid = $state(false);
|
||||||
|
let behaviorInvalid = $state(false);
|
||||||
let createOutcomeUnknown = $state(false);
|
let createOutcomeUnknown = $state(false);
|
||||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||||
let createButton = $state<HTMLButtonElement>();
|
let createButton = $state<HTMLButtonElement>();
|
||||||
let roleInput = $state<HTMLInputElement>();
|
let roleInput = $state<HTMLInputElement>();
|
||||||
|
let behaviorInput = $state<HTMLTextAreaElement>();
|
||||||
|
|
||||||
function subjectHref(subjectId: string): string {
|
function subjectHref(subjectId: string): string {
|
||||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||||
@@ -31,10 +34,15 @@
|
|||||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
|
||||||
|
return state === 'active' ? 'Available' : 'Retired';
|
||||||
|
}
|
||||||
|
|
||||||
async function openCreateForm(): Promise<void> {
|
async function openCreateForm(): Promise<void> {
|
||||||
createExpanded = true;
|
createExpanded = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
await tick();
|
await tick();
|
||||||
@@ -45,8 +53,10 @@
|
|||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
createExpanded = false;
|
createExpanded = false;
|
||||||
role = '';
|
role = '';
|
||||||
|
behaviorMd = '';
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
await tick();
|
await tick();
|
||||||
@@ -58,9 +68,13 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
createOutcomeUnknown = false;
|
||||||
try {
|
try {
|
||||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
|
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
||||||
|
role,
|
||||||
|
behavior_md: behaviorMd,
|
||||||
|
});
|
||||||
createdSubject = subject;
|
createdSubject = subject;
|
||||||
try {
|
try {
|
||||||
await goto(subjectHref(subject.id));
|
await goto(subjectHref(subject.id));
|
||||||
@@ -69,14 +83,16 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SubjektivSubjectCreateError) {
|
if (error instanceof SubjektivSubjectCreateError) {
|
||||||
roleInvalid = error.kind === 'validation';
|
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
||||||
|
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
||||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||||
createError = error.status === 401 || error.status === 403
|
createError = error.status === 401 || error.status === 403
|
||||||
? 'You do not have permission to create Subjects in this Workspace.'
|
? 'You do not have permission to create Subjects in this Workspace.'
|
||||||
: error.message;
|
: error.message;
|
||||||
if (roleInvalid) {
|
if (roleInvalid || behaviorInvalid) {
|
||||||
await tick();
|
await tick();
|
||||||
roleInput?.focus();
|
if (behaviorInvalid) behaviorInput?.focus();
|
||||||
|
else roleInput?.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
createOutcomeUnknown = true;
|
createOutcomeUnknown = true;
|
||||||
@@ -109,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="memory-header-actions">
|
<div class="memory-header-actions">
|
||||||
{#if data.subjects.data}
|
{#if data.subjects.data}
|
||||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
bind:this={createButton}
|
bind:this={createButton}
|
||||||
@@ -145,9 +161,22 @@
|
|||||||
disabled={submitting || createdSubject !== null}
|
disabled={submitting || createdSubject !== null}
|
||||||
onkeydown={createFormKeydown}
|
onkeydown={createFormKeydown}
|
||||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||||
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="subject-role-field">
|
||||||
|
<span>Behavior <small>Optional, user-managed</small></span>
|
||||||
|
<textarea
|
||||||
|
bind:this={behaviorInput}
|
||||||
|
bind:value={behaviorMd}
|
||||||
|
rows="7"
|
||||||
|
disabled={submitting || createdSubject !== null}
|
||||||
|
onkeydown={createFormKeydown}
|
||||||
|
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
||||||
|
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
||||||
|
></textarea>
|
||||||
|
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
|
||||||
|
</label>
|
||||||
{#if createError}
|
{#if createError}
|
||||||
<div
|
<div
|
||||||
id="subject-create-error"
|
id="subject-create-error"
|
||||||
@@ -184,14 +213,19 @@
|
|||||||
{#each subjects as subject (subject.id)}
|
{#each subjects as subject (subject.id)}
|
||||||
<a class="subject-row" href={subjectHref(subject.id)}>
|
<a class="subject-row" href={subjectHref(subject.id)}>
|
||||||
<div class="subject-copy">
|
<div class="subject-copy">
|
||||||
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
|
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
|
||||||
<h2>{subject.role}</h2>
|
<h2>{subject.role}</h2>
|
||||||
<code title={subject.id}>{subject.id}</code>
|
<code title={subject.id}>{subject.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<dl class="subject-meta">
|
<dl class="subject-meta">
|
||||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
<div>
|
||||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
<dt>Worker connection</dt>
|
||||||
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
<dd>
|
||||||
|
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||||
|
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -309,20 +343,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subject-create-actions {
|
.subject-create-actions {
|
||||||
grid-column: 3;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-create-panel {
|
.subject-create-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
|
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
|
||||||
align-items: end;
|
align-items: start;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius-soft);
|
border-radius: var(--radius-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-create-copy {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.subject-create-copy h2,
|
.subject-create-copy h2,
|
||||||
.subject-create-copy p,
|
.subject-create-copy p,
|
||||||
.subject-create-error p {
|
.subject-create-error p {
|
||||||
@@ -344,7 +383,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-role-field input {
|
.subject-role-field input,
|
||||||
|
.subject-role-field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -354,7 +394,15 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-role-field input[aria-invalid='true'] {
|
.subject-role-field textarea {
|
||||||
|
min-height: 8rem;
|
||||||
|
resize: vertical;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-role-field input[aria-invalid='true'],
|
||||||
|
.subject-role-field textarea[aria-invalid='true'] {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +474,8 @@
|
|||||||
|
|
||||||
.subject-meta {
|
.subject-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,12 +489,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subject-meta dd {
|
.subject-meta dd {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-meta dd strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.memory-state {
|
.memory-state {
|
||||||
padding: var(--space-5) 0;
|
padding: var(--space-5) 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -480,6 +535,7 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-create-copy,
|
||||||
.subject-create-error,
|
.subject-create-error,
|
||||||
.subject-create-actions {
|
.subject-create-actions {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
|||||||
@@ -1,11 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||||
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||||
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
|
import {
|
||||||
|
SubjektivSubjectCreateError,
|
||||||
|
updateSubjektivSubjectBehavior,
|
||||||
|
} from '#lib/workspace/memory/api.ts';
|
||||||
|
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
let subject = $state(untrack(() => data.subject.data));
|
||||||
|
let editingBehavior = $state(false);
|
||||||
|
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
|
||||||
|
let behaviorSaving = $state(false);
|
||||||
|
let behaviorError = $state<string | null>(null);
|
||||||
|
let behaviorSaveConfirmed = $state(false);
|
||||||
|
|
||||||
|
let routeGeneration = 0;
|
||||||
|
let subjectWorkspaceId = untrack(() => data.workspaceId);
|
||||||
|
|
||||||
|
// SvelteKit reuses this page across Subject and Workspace routes. A new
|
||||||
|
// loader snapshot owns a new editor and fences all previous mutations.
|
||||||
|
$effect(() => {
|
||||||
|
const workspaceId = data.workspaceId;
|
||||||
|
const subjectId = data.subjectId;
|
||||||
|
const loadedSubject = data.subject.data;
|
||||||
|
untrack(() => {
|
||||||
|
routeGeneration += 1;
|
||||||
|
subjectWorkspaceId = workspaceId;
|
||||||
|
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
|
||||||
|
behaviorDraft = subject?.behavior_md ?? '';
|
||||||
|
editingBehavior = false;
|
||||||
|
behaviorSaving = false;
|
||||||
|
behaviorError = null;
|
||||||
|
behaviorSaveConfirmed = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveBehavior(): Promise<void> {
|
||||||
|
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const workspaceId = subjectWorkspaceId;
|
||||||
|
const subjectId = subject.id;
|
||||||
|
const request = {
|
||||||
|
expected_behavior_revision: subject.behavior_revision,
|
||||||
|
behavior_md: behaviorDraft,
|
||||||
|
};
|
||||||
|
const isCurrent = () => generation === routeGeneration &&
|
||||||
|
data.workspaceId === workspaceId && data.subjectId === subjectId;
|
||||||
|
behaviorSaving = true;
|
||||||
|
behaviorError = null;
|
||||||
|
behaviorSaveConfirmed = false;
|
||||||
|
try {
|
||||||
|
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
subject = updated;
|
||||||
|
behaviorDraft = updated.behavior_md;
|
||||||
|
editingBehavior = false;
|
||||||
|
behaviorSaveConfirmed = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
behaviorError = error instanceof SubjektivSubjectCreateError
|
||||||
|
? (error.status === 401 || error.status === 403
|
||||||
|
? 'You do not have permission to edit this Subject.'
|
||||||
|
: error.message)
|
||||||
|
: 'The save outcome is unknown. Reload the Subject before retrying.';
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) behaviorSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginBehaviorEdit(): void {
|
||||||
|
if (!subject) return;
|
||||||
|
behaviorDraft = subject.behavior_md;
|
||||||
|
behaviorError = null;
|
||||||
|
behaviorSaveConfirmed = false;
|
||||||
|
editingBehavior = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBehaviorEdit(): void {
|
||||||
|
behaviorDraft = subject?.behavior_md ?? '';
|
||||||
|
behaviorError = null;
|
||||||
|
editingBehavior = false;
|
||||||
|
}
|
||||||
|
|
||||||
const surface = $derived(data.surface.data);
|
const surface = $derived(data.surface.data);
|
||||||
const snapshot = $derived(surface?.snapshot ?? null);
|
const snapshot = $derived(surface?.snapshot ?? null);
|
||||||
const memories = $derived(data.memories.data?.items ?? []);
|
const memories = $derived(data.memories.data?.items ?? []);
|
||||||
@@ -36,10 +116,28 @@
|
|||||||
function kindLabel(kind: string): string {
|
function kindLabel(kind: string): string {
|
||||||
return kind.replaceAll('_', ' ');
|
return kind.replaceAll('_', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
|
||||||
|
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
|
||||||
|
if (availability === 'ungenerated') return 'Not generated';
|
||||||
|
if (availability === 'stale') return 'Needs refresh';
|
||||||
|
return 'Generation failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
|
||||||
|
if (availability !== 'ready') return 'No current generated context is available to use.';
|
||||||
|
return snapshot?.body_md.trim()
|
||||||
|
? 'Current generated context is available.'
|
||||||
|
: 'A current empty surface exists; there is no generated Memory context to show.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectStateLabel(state: 'active' | 'retired'): string {
|
||||||
|
return state === 'active' ? 'Available' : 'Retired';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||||
<meta name="description" content="Subject resident surface and current committed Memories" />
|
<meta name="description" content="Subject resident surface and current committed Memories" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -49,20 +147,57 @@
|
|||||||
<header class="memory-page-header">
|
<header class="memory-page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="memory-eyebrow">Memory subject</p>
|
<p class="memory-eyebrow">Memory subject</p>
|
||||||
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
|
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
|
||||||
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
||||||
</div>
|
</div>
|
||||||
<span class="read-only-label">Read-only</span>
|
<span class="read-only-label">Behavior editable</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if data.subject.data}
|
{#if subject}
|
||||||
<dl class="subject-facts" aria-label="Subject details">
|
<section class="subject-overview" aria-label="Subject status">
|
||||||
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
|
<div>
|
||||||
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
|
<span>Subject status</span>
|
||||||
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
|
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
|
||||||
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
|
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
|
||||||
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
|
</div>
|
||||||
</dl>
|
<div>
|
||||||
|
<span>Worker connection</span>
|
||||||
|
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||||
|
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Committed Memories</span>
|
||||||
|
{#if data.memories.data}
|
||||||
|
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
|
||||||
|
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
|
||||||
|
{:else}
|
||||||
|
<strong>Unavailable</strong>
|
||||||
|
<p>The current Memory list could not be read.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Generated Memory context</span>
|
||||||
|
{#if surface}
|
||||||
|
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
||||||
|
<p>{surfaceSummary(surface.availability)}</p>
|
||||||
|
{:else}
|
||||||
|
<strong>Unavailable</strong>
|
||||||
|
<p>The resident context status could not be read.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="subject-technical-details">
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Subject store revision</dt>
|
||||||
|
<dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
{:else if data.subject.error}
|
{:else if data.subject.error}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Subject details unavailable.</strong>
|
<strong>Subject details unavailable.</strong>
|
||||||
@@ -72,41 +207,77 @@
|
|||||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if subject}
|
||||||
|
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
|
||||||
|
<header class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="memory-eyebrow">User-managed context</p>
|
||||||
|
<h2 id="subject-behavior-heading">Behavior</h2>
|
||||||
|
</div>
|
||||||
|
{#if !editingBehavior}
|
||||||
|
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.</p>
|
||||||
|
{#if editingBehavior}
|
||||||
|
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">Subject behavior</span>
|
||||||
|
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
|
||||||
|
</label>
|
||||||
|
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
|
||||||
|
<div class="behavior-actions">
|
||||||
|
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
|
||||||
|
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
|
||||||
|
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else if subject.behavior_md.length > 0}
|
||||||
|
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
|
||||||
|
{:else}
|
||||||
|
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
|
||||||
|
{/if}
|
||||||
|
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="memory-eyebrow">Resident context</p>
|
<p class="memory-eyebrow">Generated from committed Memory</p>
|
||||||
<h2 id="resident-surface-heading">Resident surface</h2>
|
<h2 id="resident-surface-heading">Resident context</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if surface}
|
{#if surface}
|
||||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if surface?.availability === 'ready' && snapshot}
|
{#if surface?.availability === 'ready' && snapshot}
|
||||||
<div class="surface-meta">
|
<div class="surface-meta">
|
||||||
<span>Built from store revision {snapshot.built_from_store_revision}</span>
|
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
|
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if snapshot.body_md.trim().length === 0}
|
{#if snapshot.body_md.trim().length === 0}
|
||||||
<div class="memory-state" role="status" data-surface-ready-empty>
|
<div class="memory-state" role="status" data-surface-ready-empty>
|
||||||
<strong>Resident surface is ready and empty.</strong>
|
<strong>Resident context is current but empty.</strong>
|
||||||
<p>This subject currently has no resident context to display.</p>
|
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<article class="surface-document" aria-label="Resident surface">
|
<article class="surface-document" aria-label="Resident context">
|
||||||
<DocumentMarkdown text={snapshot.body_md} />
|
<DocumentMarkdown text={snapshot.body_md} />
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
<details class="surface-details">
|
<details class="surface-details">
|
||||||
<summary>Surface provenance</summary>
|
<summary>Surface sources and diagnostics</summary>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Memory refs</dt>
|
<dt>Generated from Subject revision</dt>
|
||||||
|
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Referenced Memory revisions</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{#if snapshot.memory_refs.length === 0}
|
{#if snapshot.memory_refs.length === 0}
|
||||||
None
|
None
|
||||||
@@ -123,26 +294,26 @@
|
|||||||
</details>
|
</details>
|
||||||
{:else if surface?.availability === 'ungenerated'}
|
{:else if surface?.availability === 'ungenerated'}
|
||||||
<div class="memory-state" role="status">
|
<div class="memory-state" role="status">
|
||||||
<strong>Resident surface has not been generated.</strong>
|
<strong>Resident context has not been generated.</strong>
|
||||||
<p>Committed Memories remain available below.</p>
|
<p>Committed Memories, if any, remain available below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if surface?.availability === 'stale'}
|
{:else if surface?.availability === 'stale'}
|
||||||
<div class="memory-state is-warning" role="status">
|
<div class="memory-state is-warning" role="status">
|
||||||
<strong>Resident surface is stale.</strong>
|
<strong>Resident context needs to be refreshed.</strong>
|
||||||
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
|
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if surface?.availability === 'failed'}
|
{:else if surface?.availability === 'failed'}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Resident surface generation failed.</strong>
|
<strong>Resident context generation failed.</strong>
|
||||||
<p>No failed or partial snapshot is displayed.</p>
|
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.surface.error}
|
{:else if data.surface.error}
|
||||||
<div class="memory-state is-error" role="alert">
|
<div class="memory-state is-error" role="alert">
|
||||||
<strong>Resident surface unavailable.</strong>
|
<strong>Resident context status unavailable.</strong>
|
||||||
<p>{data.surface.error}</p>
|
<p>{data.surface.error}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
|
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -152,14 +323,14 @@
|
|||||||
<p class="memory-eyebrow">Committed records</p>
|
<p class="memory-eyebrow">Committed records</p>
|
||||||
<h2 id="current-memories-heading">Current Memories</h2>
|
<h2 id="current-memories-heading">Current Memories</h2>
|
||||||
</div>
|
</div>
|
||||||
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
|
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if data.memories.data}
|
{#if data.memories.data}
|
||||||
{#if memories.length === 0}
|
{#if memories.length === 0}
|
||||||
<div class="memory-state" role="status" data-memories-empty>
|
<div class="memory-state" role="status" data-memories-empty>
|
||||||
<strong>No committed Memories.</strong>
|
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
|
||||||
<p>This subject has no current Memory revisions.</p>
|
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
|
||||||
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -175,9 +346,9 @@
|
|||||||
<p>{memory.excerpt || 'No excerpt.'}</p>
|
<p>{memory.excerpt || 'No excerpt.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||||
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
|
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
|
||||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -270,25 +441,79 @@
|
|||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts {
|
.subject-overview {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
display: grid;
|
||||||
gap: var(--space-3);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts > div {
|
.subject-overview > div {
|
||||||
display: block;
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts dt {
|
.subject-overview span,
|
||||||
white-space: normal;
|
.subject-overview p,
|
||||||
|
.subject-technical-details summary,
|
||||||
|
.subject-technical-details small,
|
||||||
|
.surface-details small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts dd {
|
.subject-overview strong {
|
||||||
margin-top: var(--space-1);
|
color: var(--text-strong);
|
||||||
color: var(--text);
|
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-overview p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details {
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details summary {
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details dl,
|
||||||
|
.surface-details dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details dl > div,
|
||||||
|
.surface-details dl > div {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details dd,
|
||||||
|
.surface-details dd {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-technical-details small,
|
||||||
|
.surface-details small {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.subject-state,
|
.subject-state,
|
||||||
.availability,
|
.availability,
|
||||||
.memory-pill,
|
.memory-pill,
|
||||||
@@ -320,6 +545,7 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.behavior-section,
|
||||||
.surface-section,
|
.surface-section,
|
||||||
.memories-section {
|
.memories-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -328,6 +554,78 @@
|
|||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.behavior-help,
|
||||||
|
.behavior-saved,
|
||||||
|
.behavior-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-error { color: var(--danger); }
|
||||||
|
.behavior-saved { color: var(--success); }
|
||||||
|
|
||||||
|
.behavior-form,
|
||||||
|
.behavior-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 10rem;
|
||||||
|
padding: var(--space-3);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-edit,
|
||||||
|
.behavior-actions button {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||||
|
|
||||||
|
.behavior-document pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.surface-meta,
|
.surface-meta,
|
||||||
.pill-row {
|
.pill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -455,7 +753,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.subject-facts {
|
.subject-overview {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,10 +770,16 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subject-facts {
|
.subject-overview,
|
||||||
|
.subject-technical-details dl,
|
||||||
|
.surface-details dl {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-overview {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.memory-row {
|
.memory-row {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-9
@@ -63,6 +63,11 @@
|
|||||||
return value.replaceAll('_', ' ');
|
return value.replaceAll('_', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function titleLabel(value: string): string {
|
||||||
|
const normalized = label(value);
|
||||||
|
return normalized[0].toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
function rangeLabel(range: [number, number] | null | undefined): string {
|
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||||
}
|
}
|
||||||
@@ -98,8 +103,8 @@
|
|||||||
<header class="memory-page-header">
|
<header class="memory-page-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="pill-row">
|
<div class="pill-row">
|
||||||
<span class="memory-pill is-{memory.state}">{memory.state}</span>
|
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
|
||||||
<span class="memory-kind">{label(memory.kind)}</span>
|
<span class="memory-kind">{titleLabel(memory.kind)}</span>
|
||||||
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||||
@@ -109,11 +114,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl class="memory-facts" aria-label="Memory details">
|
<dl class="memory-facts" aria-label="Memory details">
|
||||||
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||||
<div><dt>State</dt><dd>{memory.state}</dd></div>
|
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
|
||||||
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
|
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
|
||||||
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
|
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
|
||||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
||||||
@@ -140,7 +145,7 @@
|
|||||||
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
|
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
|
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
|
||||||
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
|
<span>{memory.source_candidates.length} shown</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if memory.source_candidates.length === 0}
|
{#if memory.source_candidates.length === 0}
|
||||||
@@ -226,7 +231,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
||||||
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
|
<header class="section-heading">
|
||||||
|
<div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>
|
||||||
|
{#if data.revisions.data}<span>{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||||
|
</header>
|
||||||
|
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
|
||||||
{#if data.revisions.data}
|
{#if data.revisions.data}
|
||||||
{#if revisions.length === 0}
|
{#if revisions.length === 0}
|
||||||
<div>
|
<div>
|
||||||
@@ -238,7 +247,7 @@
|
|||||||
{#each revisions as revision (revision.revision)}
|
{#each revisions as revision (revision.revision)}
|
||||||
<li class:current={revision.revision === memory.revision}>
|
<li class:current={revision.revision === memory.revision}>
|
||||||
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
||||||
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
|
<div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
|
||||||
<p>{revision.claim}</p>
|
<p>{revision.claim}</p>
|
||||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||||
</a>
|
</a>
|
||||||
@@ -386,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.memory-facts {
|
.memory-facts {
|
||||||
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
@@ -414,6 +424,7 @@
|
|||||||
.why-useful,
|
.why-useful,
|
||||||
.staleness,
|
.staleness,
|
||||||
.change-reason,
|
.change-reason,
|
||||||
|
.section-intro,
|
||||||
.empty-copy,
|
.empty-copy,
|
||||||
.bounded-note {
|
.bounded-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ function subject() {
|
|||||||
return {
|
return {
|
||||||
id: "subject-1",
|
id: "subject-1",
|
||||||
role: "Release coordinator",
|
role: "Release coordinator",
|
||||||
|
behavior_md: "Prefer explicit evidence.",
|
||||||
|
behavior_revision: 2,
|
||||||
state: "active" as const,
|
state: "active" as const,
|
||||||
store_revision: 9,
|
store_revision: 9,
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
@@ -73,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => {
|
|||||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Not connected")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
|
||||||
|
expect(screen.getByText("1 shown · more available")).not.toBeNull();
|
||||||
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||||
.toBe("/w/workspace-1/memory");
|
.toBe("/w/workspace-1/memory");
|
||||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||||
@@ -96,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det
|
|||||||
}) as HTMLInputElement;
|
}) as HTMLInputElement;
|
||||||
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
await waitFor(() => expect(document.activeElement).toBe(roleInput));
|
||||||
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
|
||||||
|
const behaviorInput = screen.getByRole("textbox", {
|
||||||
|
name: /Behavior/,
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
await fireEvent.input(behaviorInput, {
|
||||||
|
target: { value: "Challenge unsupported assumptions." },
|
||||||
|
});
|
||||||
|
|
||||||
const submit = screen.getByRole("button", { name: "Create Subject" });
|
const submit = screen.getByRole("button", { name: "Create Subject" });
|
||||||
await fireEvent.click(submit);
|
await fireEvent.click(submit);
|
||||||
@@ -110,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det
|
|||||||
resolveRequest(Response.json({
|
resolveRequest(Response.json({
|
||||||
id: "generated-subject-1",
|
id: "generated-subject-1",
|
||||||
role: " Review lead ",
|
role: " Review lead ",
|
||||||
|
behavior_md: "Challenge unsupported assumptions.",
|
||||||
|
behavior_revision: 0,
|
||||||
state: "active",
|
state: "active",
|
||||||
store_revision: 0,
|
store_revision: 0,
|
||||||
created_at: "2026-01-03T00:00:00Z",
|
created_at: "2026-01-03T00:00:00Z",
|
||||||
@@ -127,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det
|
|||||||
];
|
];
|
||||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||||
expect(init.method).toBe("POST");
|
expect(init.method).toBe("POST");
|
||||||
expect(init.body).toBe('{"role":" Review lead "}');
|
expect(init.body).toBe(
|
||||||
|
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
||||||
@@ -146,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
|
|||||||
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
|
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves invalid and rejected role drafts with accessible errors", async () => {
|
test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
|
||||||
const fetchMock = vi.fn(() =>
|
const fetchMock = vi.fn(() =>
|
||||||
Promise.resolve(Response.json(
|
Promise.resolve(Response.json(
|
||||||
{
|
{
|
||||||
@@ -172,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
await fireEvent.input(input, { target: { value: "Release reviewer" } });
|
||||||
|
const behaviorInput = screen.getByRole("textbox", {
|
||||||
|
name: /Behavior/,
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
await fireEvent.input(behaviorInput, { target: { value: " " } });
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
|
"empty or contain non-whitespace",
|
||||||
|
);
|
||||||
|
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await fireEvent.input(behaviorInput, {
|
||||||
|
target: { value: "Challenge unsupported assumptions." },
|
||||||
|
});
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert").textContent).toContain(
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
@@ -214,7 +243,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
subjectId: "subject-1",
|
subjectId: "subject-1",
|
||||||
cursor: "memory-current",
|
cursor: "memory-current",
|
||||||
subject: result(subject()),
|
subject: result({
|
||||||
|
...subject(),
|
||||||
|
current_worker: { display_name: "Release Worker" },
|
||||||
|
}),
|
||||||
surface: result({
|
surface: result({
|
||||||
subject_id: "subject-1",
|
subject_id: "subject-1",
|
||||||
availability: "ready" as const,
|
availability: "ready" as const,
|
||||||
@@ -267,9 +299,20 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||||
.not.toBeNull();
|
.not.toBeNull();
|
||||||
});
|
});
|
||||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Prefer explicit evidence.");
|
||||||
|
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getByText("Subject store revision", { exact: true })).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
|
||||||
|
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
||||||
@@ -280,6 +323,359 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
|||||||
.toContain("cursor=memory-next");
|
.toContain("cursor=memory-next");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||||
|
const request = JSON.parse(String(init?.body));
|
||||||
|
return Response.json({
|
||||||
|
...subject(),
|
||||||
|
behavior_md: request.behavior_md,
|
||||||
|
behavior_revision: request.expected_behavior_revision + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
render(SubjectPage, {
|
||||||
|
data: {
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
subjectId: "subject-1",
|
||||||
|
subject: result(subject()),
|
||||||
|
surface: result({
|
||||||
|
subject_id: "subject-1",
|
||||||
|
availability: "ungenerated" as const,
|
||||||
|
}),
|
||||||
|
memories: result({ items: [], has_more: false }),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||||
|
await fireEvent.input(textarea, {
|
||||||
|
target: { value: "Ask before irreversible actions." },
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
|
||||||
|
expect(init.method).toBe("PATCH");
|
||||||
|
expect(init.body).toBe(
|
||||||
|
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
|
||||||
|
)
|
||||||
|
.toContain("does not confirm application");
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Ask before irreversible actions.");
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||||
|
const secondBody =
|
||||||
|
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
|
||||||
|
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("No behavior is set.")).not.toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
subjectId: record.id,
|
||||||
|
subject: result(record),
|
||||||
|
surface: result({
|
||||||
|
subject_id: record.id,
|
||||||
|
availability: "ungenerated" as const,
|
||||||
|
}),
|
||||||
|
memories: result({ items: [], has_more: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["subject-2", "workspace-1"],
|
||||||
|
["subject-1", "workspace-2"],
|
||||||
|
])(
|
||||||
|
"resets reused Subject editor for %s in %s and saves only its own behavior",
|
||||||
|
async (id, workspaceId) => {
|
||||||
|
const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." };
|
||||||
|
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
|
||||||
|
const request = JSON.parse(String(init?.body));
|
||||||
|
return Response.json({
|
||||||
|
...nextSubject,
|
||||||
|
behavior_md: request.behavior_md,
|
||||||
|
behavior_revision: request.expected_behavior_revision + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByRole("textbox", { name: "Subject behavior" }),
|
||||||
|
{
|
||||||
|
target: { value: "Unsaved A draft" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await view.rerender(
|
||||||
|
{ data: subjectPageData(nextSubject, workspaceId) } as never,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("textbox", { name: "Subject behavior" }))
|
||||||
|
.toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Only B behavior.");
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("textbox", {
|
||||||
|
name: "Subject behavior",
|
||||||
|
}) as HTMLTextAreaElement).value,
|
||||||
|
)
|
||||||
|
.toBe("Only B behavior.");
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Save behavior" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
const [path, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(path).toBe(
|
||||||
|
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
expected_behavior_revision: 2,
|
||||||
|
behavior_md: "Only B behavior.",
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(["success", "failure"])(
|
||||||
|
"fences late %s from a previous Subject without clearing the new save state",
|
||||||
|
async (outcome) => {
|
||||||
|
const pending: Array<(response: Response) => void> = [];
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
new Promise<Response>((resolve) => pending.push(resolve))
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Save behavior" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const nextSubject = {
|
||||||
|
...subject(),
|
||||||
|
id: "subject-2",
|
||||||
|
behavior_md: "Only B behavior.",
|
||||||
|
};
|
||||||
|
await view.rerender({ data: subjectPageData(nextSubject) } as never);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByRole("textbox", { name: "Subject behavior" }),
|
||||||
|
{
|
||||||
|
target: { value: "Updated B behavior." },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Save behavior" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||||
|
pending[0](
|
||||||
|
outcome === "success"
|
||||||
|
? Response.json({
|
||||||
|
...subject(),
|
||||||
|
behavior_md: "Late A response",
|
||||||
|
behavior_revision: 3,
|
||||||
|
})
|
||||||
|
: Response.json({
|
||||||
|
error: "Forbidden",
|
||||||
|
message: "A denied",
|
||||||
|
diagnostics: [],
|
||||||
|
}, { status: 403 }),
|
||||||
|
);
|
||||||
|
// Drain the previous response through its success/catch/finally branches.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("textbox", {
|
||||||
|
name: "Subject behavior",
|
||||||
|
}) as HTMLTextAreaElement).value,
|
||||||
|
)
|
||||||
|
.toBe("Updated B behavior.");
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
expect(screen.queryByText(/storage confirmed/)).toBeNull();
|
||||||
|
pending[1](
|
||||||
|
Response.json({
|
||||||
|
...nextSubject,
|
||||||
|
behavior_md: "Updated B behavior.",
|
||||||
|
behavior_revision: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Updated B behavior.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => {
|
||||||
|
let resolveRequest!: (response: Response) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||||
|
await view.rerender(
|
||||||
|
{ data: subjectPageData({ ...subject(), id: "subject-2" }) } as never,
|
||||||
|
);
|
||||||
|
const fresh = {
|
||||||
|
...subject(),
|
||||||
|
behavior_md: "Fresh A loader",
|
||||||
|
behavior_revision: 4,
|
||||||
|
};
|
||||||
|
await view.rerender({ data: subjectPageData(fresh) } as never);
|
||||||
|
resolveRequest(
|
||||||
|
Response.json({
|
||||||
|
...subject(),
|
||||||
|
behavior_md: "Stale A response",
|
||||||
|
behavior_revision: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Fresh A loader");
|
||||||
|
expect(screen.queryByText(/storage confirmed/)).toBeNull();
|
||||||
|
|
||||||
|
await view.rerender(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...subjectPageData(),
|
||||||
|
subject: result(null, "Subject unavailable"),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("article", { name: "User-managed Subject behavior" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
|
||||||
|
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
|
||||||
|
// stages a candidate without committing a Memory. Its public read projection is
|
||||||
|
// the same empty committed list as a new Subject; candidates are not a page input.
|
||||||
|
render(SubjectPage, {
|
||||||
|
data: {
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
subjectId: "subject-1",
|
||||||
|
cursor: null,
|
||||||
|
subject: result({ ...subject(), store_revision: 0 }),
|
||||||
|
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
|
||||||
|
memories: result({ limit: 100, items: [], has_more: false }),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(screen.getByText("None", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
||||||
|
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole("article", { name: "Resident context" }))
|
||||||
|
.toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
/candidate count|all Memories lost|automatically updating/i,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||||
|
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
|
||||||
|
async (availability) => {
|
||||||
|
const record = { ...subject(), store_revision: 0 };
|
||||||
|
const view = render(SubjectPage, {
|
||||||
|
data: {
|
||||||
|
...subjectPageData(record),
|
||||||
|
surface: result({
|
||||||
|
subject_id: record.id,
|
||||||
|
availability,
|
||||||
|
...(availability === "ready"
|
||||||
|
? {
|
||||||
|
snapshot: {
|
||||||
|
snapshot_id: "empty-snapshot",
|
||||||
|
body_md: "",
|
||||||
|
memory_refs: [],
|
||||||
|
built_from_store_revision: 0,
|
||||||
|
created_at: "2026-01-02T03:04:05Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain(record.behavior_md);
|
||||||
|
expect(screen.getByText("Generated Memory context", { exact: true })).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
||||||
|
const technical = view.container.querySelector(
|
||||||
|
"details.subject-technical-details",
|
||||||
|
) as HTMLDetailsElement;
|
||||||
|
expect(technical.open).toBe(false);
|
||||||
|
expect(technical.textContent).toContain("Subject store revision");
|
||||||
|
if (availability === "ready") {
|
||||||
|
expect(screen.getByText("Resident context is current but empty.")).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(screen.getByText(/no generated Memory context to show/)).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(
|
||||||
|
view.container.querySelector("details.surface-details")?.textContent,
|
||||||
|
)
|
||||||
|
.toContain("Generated from Subject revision");
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||||
|
.textContent,
|
||||||
|
)
|
||||||
|
.toContain(record.behavior_md);
|
||||||
|
expect(technical.open).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||||
const base = {
|
const base = {
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
@@ -303,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
expect(screen.getByText("Resident context is current but empty.")).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
|
expect(screen.getByText(/current empty surface exists/)).not.toBeNull();
|
||||||
|
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||||
|
|
||||||
|
await view.rerender(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...base,
|
||||||
|
surface: result({
|
||||||
|
subject_id: "subject-1",
|
||||||
|
availability: "ungenerated" as const,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Resident context has not been generated.")).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||||
|
|
||||||
await view.rerender(
|
await view.rerender(
|
||||||
{
|
{
|
||||||
@@ -317,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
|
expect(screen.getByText("Resident context needs to be refreshed.")).not
|
||||||
|
.toBeNull();
|
||||||
|
|
||||||
await view.rerender(
|
await view.rerender(
|
||||||
{
|
{
|
||||||
@@ -333,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
||||||
|
|
||||||
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
||||||
expect(screen.getByText("Resident surface data is unavailable.")).not
|
expect(screen.getByText("Resident context status is unavailable.")).not
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
|
|
||||||
await view.rerender(
|
await view.rerender(
|
||||||
@@ -342,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
|||||||
} as never,
|
} as never,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("alert").textContent).toContain(
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
"Resident surface unavailable",
|
"Resident context status unavailable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1859,45 +1859,6 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="console-shell worker-console-shell">
|
<div class="console-shell worker-console-shell">
|
||||||
<section class="console-header card" aria-label="Worker controls">
|
|
||||||
<WorkerContextStatus metadata={selectedConsoleProjection.workerMetadata} />
|
|
||||||
<div class="console-header-actions">
|
|
||||||
<div
|
|
||||||
class="console-view-modes"
|
|
||||||
role="group"
|
|
||||||
aria-label="Console display mode"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class:active={consoleViewMode === "overview"}
|
|
||||||
aria-pressed={consoleViewMode === "overview"}
|
|
||||||
onclick={() => (consoleViewMode = "overview")}
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class:active={consoleViewMode === "normal"}
|
|
||||||
aria-pressed={consoleViewMode === "normal"}
|
|
||||||
onclick={() => (consoleViewMode = "normal")}
|
|
||||||
>
|
|
||||||
Normal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
aria-expanded={workerDetailsOpen}
|
|
||||||
onclick={() => {
|
|
||||||
workerDetailsOpen = !workerDetailsOpen;
|
|
||||||
if (workerDetailsOpen) taskPaneOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if rewindTargets.length > 0}
|
{#if rewindTargets.length > 0}
|
||||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||||
<h3>Rewind targets</h3>
|
<h3>Rewind targets</h3>
|
||||||
@@ -2324,6 +2285,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<WorkerContextStatus
|
||||||
|
metadata={selectedConsoleProjection.workerMetadata}
|
||||||
|
detailsOpen={workerDetailsOpen}
|
||||||
|
onToggleDetails={() => {
|
||||||
|
workerDetailsOpen = !workerDetailsOpen;
|
||||||
|
if (workerDetailsOpen) taskPaneOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet controls()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="console-view-toggle"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={consoleViewMode === "overview"}
|
||||||
|
title="On: Overview · Off: Normal"
|
||||||
|
onclick={() => (consoleViewMode = consoleViewMode === "overview" ? "normal" : "overview")}
|
||||||
|
>
|
||||||
|
<span class="console-view-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span>Overview</span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</WorkerContextStatus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -2349,14 +2332,6 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-card {
|
.console-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -2364,46 +2339,59 @@
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header-actions {
|
.console-view-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: var(--space-1);
|
||||||
flex-wrap: wrap;
|
min-height: var(--space-5);
|
||||||
gap: var(--space-2);
|
padding: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.console-view-modes {
|
|
||||||
display: inline-flex;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-view-modes button {
|
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: var(--space-1);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: var(--space-1) var(--space-2);
|
font-family: var(--font-sans);
|
||||||
font: inherit;
|
|
||||||
font-size: var(--font-size-compact);
|
font-size: var(--font-size-compact);
|
||||||
line-height: var(--line-height-compact);
|
line-height: var(--line-height-compact);
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-view-modes button + button {
|
.console-view-toggle:hover {
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-view-modes button:hover {
|
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-view-modes button.active {
|
.console-view-toggle:focus-visible {
|
||||||
background: var(--accent);
|
outline: 1px solid var(--text-strong);
|
||||||
color: var(--bg);
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-view-toggle-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 24px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-view-toggle-track::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-view-toggle[aria-checked="true"] .console-view-toggle-track {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-view-toggle[aria-checked="true"] .console-view-toggle-track::before {
|
||||||
|
transform: translateX(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rewind-targets {
|
.rewind-targets {
|
||||||
@@ -2907,14 +2895,5 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-header-actions {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+18
-3
@@ -530,13 +530,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
|
|||||||
await screen.findByText("Start of conversation");
|
await screen.findByText("Start of conversation");
|
||||||
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||||
expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1);
|
expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1);
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Normal" }));
|
const footer = screen.getByLabelText("Worker model and context");
|
||||||
|
const overview = screen.getByRole("switch", { name: "Overview" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Normal" })).toBeNull();
|
||||||
|
const details = screen.getByRole("button", { name: "Details" });
|
||||||
|
expect(view.container.querySelector(".console-header")).toBeNull();
|
||||||
|
expect(footer.contains(overview)).toBe(true);
|
||||||
|
expect(overview.nextElementSibling).toBe(details);
|
||||||
|
expect(overview.closest("form")).toBeNull();
|
||||||
|
expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer);
|
||||||
|
expect(overview.getAttribute("aria-checked")).toBe("true");
|
||||||
|
await fireEvent.click(overview);
|
||||||
|
expect(overview.getAttribute("aria-checked")).toBe("false");
|
||||||
|
expect(overview.textContent?.trim()).toBe("Overview");
|
||||||
const transcript = screen.getByRole("article", { name: "main transcript" });
|
const transcript = screen.getByRole("article", { name: "main transcript" });
|
||||||
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
|
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
|
||||||
expect(ids).toHaveLength(3);
|
expect(ids).toHaveLength(3);
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Overview" }));
|
await fireEvent.click(overview);
|
||||||
|
expect(overview.getAttribute("aria-checked")).toBe("true");
|
||||||
|
expect(overview.textContent?.trim()).toBe("Overview");
|
||||||
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||||
|
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -744,7 +759,7 @@ test("mini task summary opens and closes details without a header Tasks button",
|
|||||||
latestListener().onFrame(subscribedFrame(session));
|
latestListener().onFrame(subscribedFrame(session));
|
||||||
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
||||||
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
|
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
|
||||||
expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks");
|
expect(view.container.querySelector(".console-header")).toBeNull();
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
|
||||||
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
||||||
expect(details.textContent).not.toContain("Capabilities");
|
expect(details.textContent).not.toContain("Capabilities");
|
||||||
|
|||||||
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
|||||||
entrypoints: [],
|
entrypoints: [],
|
||||||
}, fetcher);
|
}, fetcher);
|
||||||
|
|
||||||
assertEquals(calls.map((call) => call.url), [
|
assertEquals<unknown>(calls.map((call) => call.url), [
|
||||||
"/api/w/w%2Fone/config/source-tree",
|
"/api/w/w%2Fone/config/source-tree",
|
||||||
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
||||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||||
"/api/w/w%2Fone/config/source-tree/commit",
|
"/api/w/w%2Fone/config/source-tree/commit",
|
||||||
]);
|
]);
|
||||||
assertEquals(calls[3].init?.method, "POST");
|
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||||
assert(
|
assert(
|
||||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
|
||||||
|
return {
|
||||||
|
...tree,
|
||||||
|
contract: {
|
||||||
|
...tree.contract,
|
||||||
|
schema_bundle: {
|
||||||
|
...tree.contract.schema_bundle,
|
||||||
|
source,
|
||||||
|
contributions: [{
|
||||||
|
provider_id: "builtin",
|
||||||
|
namespace: "workspace",
|
||||||
|
version: "1",
|
||||||
|
source: contributionSource,
|
||||||
|
source_digest: "sha256:contribution",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["bundle", "contribution"] as const) {
|
||||||
|
Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => {
|
||||||
|
const source = "schema body\n".repeat(6000);
|
||||||
|
const body = field === "bundle"
|
||||||
|
? withSchema(source)
|
||||||
|
: withSchema("builtin", source);
|
||||||
|
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||||
|
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
|
||||||
|
assertEquals<unknown>(
|
||||||
|
await commitConfigTree("w", {
|
||||||
|
base_revision: 7,
|
||||||
|
base_digest: snapshot.digest,
|
||||||
|
changes: [],
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
}, fetcher),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => {
|
||||||
|
const body = (source: unknown) =>
|
||||||
|
field === "bundle" ? withSchema(source) : withSchema("builtin", source);
|
||||||
|
const limit = 8 * 1024 * 1024;
|
||||||
|
const atLimit = "é".repeat(limit / 2);
|
||||||
|
assertEquals<unknown>(
|
||||||
|
parseWorkspaceConfigTreeResponse(body(atLimit)),
|
||||||
|
body(atLimit),
|
||||||
|
);
|
||||||
|
for (const invalid of [atLimit + "x", null, 42, {}]) {
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceConfigTreeResponse(body(invalid)),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("config source API retains metadata and total response limits", async () => {
|
||||||
|
const invalid = withSchema("builtin");
|
||||||
|
invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat(
|
||||||
|
4097,
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceConfigTreeResponse(invalid),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const headers of [
|
||||||
|
new Headers(),
|
||||||
|
new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }),
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const fetcher = (() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
)) as typeof fetch;
|
||||||
|
await assertRejects(
|
||||||
|
() => fetchConfigTree("w", fetcher),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
"too large",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("config source API rejects unknown response fields", async () => {
|
Deno.test("config source API rejects unknown response fields", async () => {
|
||||||
const fetcher =
|
const fetcher =
|
||||||
(() =>
|
(() =>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
parseSubjektivSubjectListResponse,
|
parseSubjektivSubjectListResponse,
|
||||||
parseSubjektivSubjectResponse,
|
parseSubjektivSubjectResponse,
|
||||||
SubjektivSubjectCreateError,
|
SubjektivSubjectCreateError,
|
||||||
|
updateSubjektivSubjectBehavior,
|
||||||
validateSubjektivSubjectCreateRequest,
|
validateSubjektivSubjectCreateRequest,
|
||||||
} from "../src/lib/workspace/memory/api.ts";
|
} from "../src/lib/workspace/memory/api.ts";
|
||||||
|
|
||||||
@@ -61,9 +62,7 @@ async function assertSubjectCreateRejects(
|
|||||||
assertEquals(error.status, expectedStatus);
|
assertEquals(error.status, expectedStatus);
|
||||||
if (!error.message.includes(expectedMessage)) {
|
if (!error.message.includes(expectedMessage)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`expected ${JSON.stringify(error.message)} to include ${
|
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
|
||||||
JSON.stringify(expectedMessage)
|
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +74,8 @@ function subject(id = "subject-1") {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role: "Release coordinator",
|
role: "Release coordinator",
|
||||||
|
behavior_md: "Prefer explicit evidence.",
|
||||||
|
behavior_revision: 2,
|
||||||
state: "active",
|
state: "active",
|
||||||
store_revision: 12,
|
store_revision: 12,
|
||||||
created_at: "2026-09-01T00:00:00Z",
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
@@ -82,6 +83,30 @@ function subject(id = "subject-1") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentWorker() {
|
||||||
|
return {
|
||||||
|
runtime_id: "runtime-1",
|
||||||
|
worker_id: "worker-1",
|
||||||
|
host_id: "host-1",
|
||||||
|
display_name: "Release Worker",
|
||||||
|
label: "Release Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
singleton_key: "subjektiv:subject-1",
|
||||||
|
tags: [],
|
||||||
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: "workspace-1",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
},
|
||||||
|
state: "idle",
|
||||||
|
last_seen_at: "2026-09-02T00:00:00Z",
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "retained",
|
||||||
|
implementation: { kind: "embedded", display_hint: "Embedded" },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function queryItem(id = "memory-1") {
|
function queryItem(id = "memory-1") {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -187,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
|||||||
() =>
|
() =>
|
||||||
parseSubjektivSubjectListResponse({
|
parseSubjektivSubjectListResponse({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
items: Array.from({ length: 101 }, (_, index) =>
|
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||||
subject(`subject-${index}`)),
|
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"bounded array",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject parser preserves live Worker connection state", () => {
|
||||||
|
assertEquals(
|
||||||
|
parseSubjektivSubjectResponse({
|
||||||
|
...subject(),
|
||||||
|
current_worker: currentWorker(),
|
||||||
|
}),
|
||||||
|
{ ...subject(), current_worker: currentWorker() },
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
parseSubjektivSubjectResponse({ ...subject(), current_worker: null }),
|
||||||
|
{ ...subject(), current_worker: null },
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseSubjektivSubjectResponse({
|
||||||
|
...subject(),
|
||||||
|
current_worker: { display_name: "Incomplete Worker" },
|
||||||
|
}),
|
||||||
|
"current worker.runtime_id",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||||
const response = { limit: 100, items: [subject()], has_more: false };
|
const response = { limit: 100, items: [subject()], has_more: false };
|
||||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||||
@@ -228,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
|||||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||||
{ role: " Release coordinator " },
|
{ role: " Release coordinator ", behavior_md: "" },
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||||
@@ -242,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
|||||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||||
"at most 256 bytes",
|
"at most 256 bytes",
|
||||||
);
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
validateSubjektivSubjectCreateRequest({
|
||||||
|
role: "Reviewer",
|
||||||
|
behavior_md: " ".repeat(3),
|
||||||
|
}),
|
||||||
|
"empty or contain non-whitespace",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
validateSubjektivSubjectCreateRequest({
|
||||||
|
role: "Reviewer",
|
||||||
|
behavior_md: "x".repeat(16 * 1024 + 1),
|
||||||
|
}),
|
||||||
|
"at most 16384 bytes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
||||||
|
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||||
|
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||||
|
requests.push({ path: String(path), init });
|
||||||
|
return Response.json({
|
||||||
|
...subject("subject/one"),
|
||||||
|
behavior_md: "Line one.\nLine two.",
|
||||||
|
behavior_revision: 3,
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const updated = await updateSubjektivSubjectBehavior(
|
||||||
|
fetchFn,
|
||||||
|
"workspace one",
|
||||||
|
"subject/one",
|
||||||
|
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
|
||||||
|
);
|
||||||
|
assertEquals(updated.behavior_revision, 3);
|
||||||
|
assertEquals(updated.behavior_md, "Line one.\nLine two.");
|
||||||
|
assertEquals(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
|
||||||
|
);
|
||||||
|
assertEquals(requests[0].init?.method, "PATCH");
|
||||||
|
assertEquals(
|
||||||
|
requests[0].init?.body,
|
||||||
|
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
|
||||||
|
const conflictFetch =
|
||||||
|
(async () =>
|
||||||
|
Response.json({ message: "revision conflict" }, {
|
||||||
|
status: 409,
|
||||||
|
})) as typeof fetch;
|
||||||
|
await assertSubjectCreateRejects(
|
||||||
|
() =>
|
||||||
|
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
|
||||||
|
expected_behavior_revision: 1,
|
||||||
|
behavior_md: "new",
|
||||||
|
}),
|
||||||
|
"rejected",
|
||||||
|
"changed elsewhere",
|
||||||
|
409,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
|
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
|
||||||
@@ -251,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
...subject("subject-created"),
|
...subject("subject-created"),
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
}, { status: 201 });
|
}, { status: 201 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
});
|
});
|
||||||
assertEquals(created, {
|
assertEquals(created, {
|
||||||
...subject("subject-created"),
|
...subject("subject-created"),
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.",
|
||||||
});
|
});
|
||||||
assertEquals(requests.length, 1);
|
assertEquals(requests.length, 1);
|
||||||
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
|
||||||
assertEquals(requests[0].init?.method, "POST");
|
assertEquals(requests[0].init?.method, "POST");
|
||||||
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
|
assertEquals(
|
||||||
|
requests[0].init?.body,
|
||||||
|
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {
|
||||||
@@ -328,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
throw new TypeError("connection reset");
|
throw new TypeError("connection reset");
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() =>
|
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||||
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"may have been created",
|
"may have been created",
|
||||||
);
|
);
|
||||||
@@ -433,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
|||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseSubjektivMemoryQueryResponse({
|
parseSubjektivMemoryQueryResponse({
|
||||||
items: Array.from({ length: 101 }, (_, index) =>
|
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||||
queryItem(`memory-${index}`)),
|
|
||||||
has_more: false,
|
has_more: false,
|
||||||
}),
|
}),
|
||||||
"bounded array",
|
"bounded array",
|
||||||
|
|||||||
Reference in New Issue
Block a user